Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

harbor-rs

harbor-rs is reusable Rust cross-compilation and toolchain infrastructure for Nix flakes. It packages the common setup needed for Rust workspaces that target Linux, Windows, and macOS without forcing each project to rebuild the same Nix plumbing.

The library exposes:

  • mkToolchain for Rust toolchains with crane
  • mkCargoConfig for generated .cargo/config.toml
  • mkCross for MinGW and optional osxcross environments
  • mkDevShell and mkDevShells for consistent development shells
  • mkGpuRenderPin for GPU/driver identity checks in visual snapshot tests
  • mkAppImage and mkFlatpakManifest for packaging outputs
  • mkAdapter and mkAtticPush for binary cache integration

Start with Installation if you want to consume the flake in another project, or jump to Quick Start for a minimal cross-compiling shell.

Source repository: https://github.com/caniko/harbor-rs

Installation

Use harbor-rs as a flake input and follow its pinned shared dependencies from your project.

Requirements

  • Nix with flakes enabled
  • rust-overlay applied to the pkgs you pass into mkToolchain
  • nix-direnv, if you use the recommended .envrc
  • A macOS SDK only if you want Darwin cross-compilation through osxcross

Add the flake input

Pin a published trunk revision. Follow only inputs Harbor actually exposes:

{
  inputs = {
    harbor-rs.url = "git+https://github.com/caniko/harbor-rs.git?ref=trunk&rev=a5b436d24e175042c76d90fc4dbd31e92f7e1721";

    nixpkgs.follows = "harbor-rs/nixpkgs";
    rust-overlay.follows = "harbor-rs/rust-overlay";
    crane.follows = "harbor-rs/crane";
  };
}

Ready-made starting points:

nix flake init -t git+https://github.com/caniko/harbor-rs.git
nix flake init -t git+https://github.com/caniko/harbor-rs.git#bevy

For a working configuration after the input is added, continue to Quick Start.

Quick Start

The fastest path is to create a toolchain, generate cross helpers, then feed both into mkDevShells.

{
  outputs = { self, nixpkgs, harbor-rs, flake-utils, rust-overlay, ... }:
    flake-utils.lib.eachDefaultSystem (system: let
      pkgs = import nixpkgs {
        inherit system;
        overlays = [(import rust-overlay)];
      };

      toolchain = harbor-rs.lib.mkToolchain { inherit pkgs; };
      cross = harbor-rs.lib.mkCross { inherit pkgs system; };
    in {
      devShells = harbor-rs.lib.mkDevShells {
        inherit pkgs cross;
        inherit (toolchain) craneLib;
      };
    });
}

This produces four shells:

  • nix develop for native work
  • nix develop .#windows for MinGW cross builds
  • nix develop .#macos for osxcross, when a macOS SDK is configured
  • nix develop .#cross for both Windows and macOS helpers together

If you need more control over the generated shell environment, read mkDevShell and mkDevShells. If you need macOS SDK setup, read macOS SDK Initialization.

mkToolchain

mkToolchain creates the Rust toolchain and craneLib used by the rest of the flake.

Parameters

  • pkgs (required): nixpkgs with rust-overlay applied
  • toolchainProfile: optional harbor-rs-owned pin, either "stable" or "nightly". Stable is currently pinned to Rust 1.98.1; nightly uses the repository’s checked-in rust-toolchain.toml (currently nightly-2026-09-15). Omitting it preserves the legacy channel/date behavior.
  • toolchainFile: optional path to a standard rust-toolchain.toml; when set, its channel, components, and targets are authoritative
  • channel: "nightly" or "stable"; defaults to "nightly"
  • date: "latest" or a pinned date such as "2025-12-01"; only used without toolchainFile
  • extensions: extra Rust components to install. Defaults to ["rust-src" "rustfmt" "rustc-codegen-cranelift-preview" "llvm-tools-preview"]. llvm-tools-preview provides llvm-cov/llvm-profdata, which cargo-llvm-cov-based coverage CI requires.
  • withRustAnalyzer: whether to include rust-analyzer in the toolchain extensions; defaults to true
  • crossTargets: list of target triples to include in the toolchain
  • cache.enable: explicitly enable the host-backed compiler cache; defaults to false

Returns

mkToolchain returns an attribute set with:

  • rustToolchain
  • craneLib
  • buildCache: the compiler-cache policy when enabled, otherwise null
  • cargoConfig: matching mkCargoConfig output, inherited automatically by harbor-rs dev shells using this craneLib
  • crossTargets

Path-patched crates and buildDepsOnly

The returned craneLib wraps Crane’s buildPackage and buildDepsOnly for workspaces that use [patch.crates-io] with local path entries.

Crane’s dependency-only phase builds a dummy source tree for path crates. That is unsafe when a registry dependency compiles against a patched local crate, because the dependency may see the dummy crate API instead of the real patched API.

For these workspaces, craneLib.buildPackage automatically disables implicit dependency artifact reuse by passing cargoArtifacts = null unless the caller already provided cargoArtifacts.

Direct craneLib.buildDepsOnly calls fail with an harbor-rs error naming the path patches. Prefer:

craneLib.buildPackage (commonArgs // {
  cargoArtifacts = null;
})

If a workspace is known to tolerate dummy path patches, pass rsHarborAllowPathPatchBuildDepsOnly = true to buildDepsOnly.

Path-patch detection reads Cargo.toml during evaluation. Direct Nix paths and lib.cleanSourceWith sources are handled automatically. If src is a generated derivation output or an undeclared plain-string path, provide the manifest explicitly to avoid import-from-derivation and store-state-dependent evaluation:

craneLib.buildPackage {
  src = generatedSource;
  rsHarborCargoTomlContents = builtins.readFile ./Cargo.toml;
  # ...
}

The harbor-rs-only argument is removed before the remaining arguments are passed to Crane.

Example

toolchain = harbor-rs.lib.mkToolchain {
  inherit pkgs;
  channel = "stable";
  date = "2026-04-01";
};

Most downstream projects only need craneLib from the result. mkDevShell and mkDevShells inherit its matching Cargo configuration automatically.

Compiler caching is host infrastructure rather than a portable toolchain default. Hosts with a managed cache transport can opt in explicitly:

toolchain = harbor-rs.lib.mkToolchain {
  inherit pkgs;
  cache.enable = true;
};

Optional fleet profiles

The profiles are opt-in. A project that wants harbor-rs to control its Rust version can select a profile and reuse the returned Cargo configuration:

toolchain = harbor-rs.lib.mkToolchain {
  inherit pkgs;
  toolchainProfile = "stable";
};
cargoConfig = toolchain.cargoConfig;

The selected profile is also inherited by mkCrossPackages for non-native outputs unless toolchainArgs is supplied explicitly. Updating the profile manifest in harbor-rs then updates every consumer when it refreshes its harbor-rs input, without requiring per-project version edits.

Projects with a checked-in Rust toolchain file can consume it directly:

toolchain = harbor-rs.lib.mkToolchain {
  inherit pkgs;
  toolchainFile = ./rust-toolchain.toml;
};

In file mode, channel and date must be omitted. Explicit extensions and crossTargets are added to the components and targets declared by the file.

mkCargoConfig

mkCargoConfig generates a .cargo/config.toml tuned for fast local builds and cross-target linker configuration.

What it configures

  • mold for Linux linker acceleration when enabled
  • lld-compatible Rust target configuration where appropriate
  • nightly-only optimizations such as shared generics and parallel frontend work
  • optional Cranelift code generation for projects that have verified compatibility
  • dev-profile defaults that reduce debug info and peak link memory
  • extra target sections for the Rust triples you care about

Parameters

  • pkgs (required)
  • channel: "nightly" or "stable". This is not toolchainProfile; that argument belongs to mkToolchain. Reuse toolchain.cargoConfig unless you need a different Cargo file than the selected toolchain.
  • crossTargets: target triples to emit configuration for
  • enableMold
  • enableCranelift (default: false)
  • enableShareGenerics
  • enableParallelFrontend
  • enableDevProfileOpts
  • devCodegenUnits
  • extraConfig: raw TOML appended to the generated file

enableCranelift (default: false)

Cranelift is opt-in because it does not yet support LLVM’s \x01 no-mangle marker used by some generated C and C++ FFI bindings. Affected projects can compile successfully but fail to link with unresolved symbols. See rustc_codegen_cranelift#1689.

Nightly projects that have verified their dependency graph can enable it:

enableCranelift = true;

The generated dev profile then uses Cranelift for workspace crates and LLVM for dependencies.

enableDevProfileOpts (default: true)

When enabled, mkCargoConfig writes a [profile.dev] section that reduces peak codegen and link memory. These settings are intended as default wins:

  • debug = "line-tables-only" keeps line tables for backtraces while dropping full DWARF.
  • split-debuginfo = "unpacked" puts remaining debug info in side files on platforms that support it; Windows treats this as a no-op.
  • [profile.dev.package."*"] debug = false drops dependency debuginfo; workspace crates keep line-table backtraces.

This flag does not set codegen-units. That is a separate tradeoff controlled by devCodegenUnits.

devCodegenUnits (default: null)

Optional positive integer. When set to N, mkCargoConfig writes codegen-units = <N> under [profile.dev]. When null, no codegen-units line is emitted and Cargo’s dev default (256) stands.

This is a tradeoff, not a pure win. Lower codegen-units values such as 32 or 16 make each rustc invocation handle larger codegen units. That can improve wall-clock time for some workspace crates, but it can also increase per-process peak RSS. Set a value only after measuring the project that will consume the config.

Cargo profile precedence means keys in a project’s Cargo.toml [profile.dev] override the same keys from this generated .cargo/config.toml. Set enableDevProfileOpts = false and devCodegenUnits = null if a project needs Cargo’s full default dev profile.

Both parameters are pure .cargo/config.toml annotations. They do not add binaries to the build environment.

Returns

  • configText: rendered TOML
  • configPath: store path to the generated config file

Example

cargoConfig = harbor-rs.lib.mkCargoConfig {
  inherit pkgs;
  extraConfig = ''
    [alias]
    xtask = "run -p xtask --"
  '';
};

Pass the result into mkDevShell or mkDevShells and harbor-rs will install it into a hash-specific directory under the user cache. That directory is exported as CARGO_HOME only when the caller has not already selected one, so the project tree stays free of generated configuration.

mkCross

mkCross assembles the cross-compilation helpers for Windows and macOS.

Windows support

MinGW toolchain components are exposed through the returned environment and can be enabled in dev shells without extra project-specific setup.

aarch64 Linux support

The returned linuxAarch64 record lifts the aarch64-unknown-linux-gnu cross boilerplate out of consumer flakes:

  • linuxAarch64.cc: the pkgsCross.aarch64-multiplatform stdenv cc
  • linuxAarch64.pkgsCross: the full pkgsCross.aarch64-multiplatform package set (handy for buildInputs such as cross openssl/wayland)
  • linuxAarch64.env: a ready-to-merge attrset with CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER, CC_aarch64_unknown_linux_gnu, and CXX_aarch64_unknown_linux_gnu

Merge linuxAarch64.env into a crane args set (along with CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu" and PKG_CONFIG_ALLOW_CROSS = "1") to cross-build for ARM64 Linux, or let mkCrossPackages wire it for you.

macOS support

osxcross is optional and turns on when one of these inputs is available:

  • macosSdk: an already realized SDK reference
  • macosSdkStorePath: explicit SDK store path, normally injected by host configuration
  • macosSdkOutputHash: the SDK’s recursive output hash; pass it together with macosSdkStorePath so the SDK becomes a sandbox-mounted build input (see the warning below)
  • sdkArchive: a local SDK archive path
  • macosSdkEnvPath: explicit local discovery path
  • MACOS_SDK: opt-in local discovery fallback when enableImpureMacosSdkEnv = true

Only one of macosSdkStorePath, sdkArchive, or macosSdk can be provided at a time. Resolution precedence is macosSdk, then macosSdkStorePath, then sdkArchive, then macosSdkEnvPath.

Pass macosSdkOutputHash with macosSdkStorePath. A bare store path carries no Nix string context, so under sandbox = true it is never bind-mounted and osxcross-clang fails with cannot find macOS SDK — with no eval-time error. The hash lets harbor-rs reconstruct the SDK as a content-addressed fixed-output derivation that resolves to the same path with context, making it a real sandbox input. See macOS SDK Initialization.

Ad-hoc signing of darwin outputs works out of the box: the osxcross toolchain provides an unprefixed codesign_allocate on PATH, so sigtool-style codesign in a postInstall needs no CODESIGN_ALLOCATE wiring. See Ad-hoc signing darwin binaries.

macosSdkEnvPath and the opt-in MACOS_SDK fallback accept a direct MacOSX<version>.sdk directory, a parent directory containing that SDK, or a supported SDK archive. Directory inputs are validated for the SDK settings file, TargetConditionals.h, and the required CoreFoundation/SystemConfiguration frameworks before osxcross is invoked.

Example

cross = harbor-rs.lib.mkCross {
  inherit pkgs system;
};

See macOS SDK Initialization for the recommended way to create and inject macosSdkStorePath from host configuration.

mkCrossPackages

mkCrossPackages builds a single Rust workspace for several targets at once, lifting the per-target crane boilerplate (separate cargoArtifacts, the right CARGO_BUILD_TARGET, and the matching cross env) out of consumer flakes. It is the reusable form of the hand-rolled cross matrix that projects such as rs-modde previously kept inline.

For NixOS consumers that need an explicit build/host contract, use mkCrossPackageOutputs. It preserves the flat package set and adds the stable namespace crossPackages.<build-system>.<host-system>:

let
  outputs = harbor-rs.lib.mkCrossPackageOutputs {
    buildSystem = "x86_64-linux";
    hostSystem = "aarch64-linux";
    inherit pkgs craneLib cross commonArgs;
    pname = "my-service";
    targets = ["aarch64-linux"];
  };
in {
  packages = outputs.packages;
  crossPackages = outputs.crossPackages;
}

The helper does not make an existing host-native derivation cross-compilable; the selected target must still be produced by mkCrossPackages with a real cross toolchain.

Signature

harbor-rs.lib.mkCrossPackages {
  pkgs;            # native build-system pkgs with rust-overlay applied
  craneLib;        # native craneLib from harbor-rs.lib.mkToolchain
  cross;           # result of harbor-rs.lib.mkCross
  pname;           # base package name, e.g. "modde"
  commonArgs;      # base crane args shared across targets; MUST include `src`
  targets ? [ "native" ];   # subset of the supported target names
  targetArgs ? {};          # optional per-target extra crane args
  toolchainArgs ? {};       # mkToolchain args for non-native targets
}

It returns an attrset of derivations keyed by output attribute name:

target nameoutput attr namebuilder
native${pname}native craneLib.buildPackage
aarch64-linux${pname}-aarch64-linuxcraneLib built from pkgsCross.aarch64-multiplatform
windows${pname}-windowsnative craneLib + cross.windowsEnv + MinGW
darwin-x86_64${pname}-darwin-x86_64cross.osxcrossRustHelpers.mkCrossBuilder
darwin-aarch64${pname}-darwin-aarch64cross.osxcrossRustHelpers.mkCrossBuilder

Only the requested targets’ attrs are returned.

Merge order

For each target, args are merged as:

  1. commonArgs (must include src)
  2. the target’s pname and cross env (CARGO_BUILD_TARGET, linker/CC vars, PKG_CONFIG_ALLOW_CROSS, …)
  3. targetArgs.<target> last, so consumers can inject project dependencies (buildInputs, nativeBuildInputs, postInstall, cargoBuildExtraArgs, doCheck, …) and override anything above.

toolchainArgs is applied when constructing the non-native Rust toolchain. Use it to preserve a project’s pinned channel/date or target list, for example toolchainArgs = { channel = "nightly"; date = "2026-02-28"; };.

Darwin fallback

The darwin-x86_64 and darwin-aarch64 targets require osxcross with a realized macOS SDK. When cross.osxcrossRustHelpers == null (no SDK configured), those outputs fall back to a runCommand that exits 1 at build time with a clear message, so the attribute still evaluates and the flake stays usable on hosts without an SDK.

System C libraries on darwin

Unlike the windows and aarch64-linux targets — where nixpkgs offers a ready cross package set (pkgs.pkgsCross.mingwW64, cross.linuxAarch64.pkgsCross) to populate buildInputs — the osxcross darwin targets get no system C libraries by default, and there is no convenient pkgsCross.*-darwin set to pull a cross openssl (or similar) from. So any crate that links a system C library through a -sys build script fails on darwin:

warning: openssl-sys@…: Could not find directory of OpenSSL installation
error: failed to run custom build command for `openssl-sys`
  The system library `openssl` required by crate `openssl-sys` was not found.

The trap is Cargo feature unification: the offending -sys crate is usually pulled in transitively and only on cfg(unix) (which includes macOS), so the break shows up on darwin while native, Windows, and Linux builds stay green. For example, git2’s default https + ssh features pull openssl-sys and libssh2-sys — you may not use them, but if any workspace member or dependency enables them, the unified darwin build links them and fails. Diagnose with cargo tree -i openssl-sys against a darwin target (--target aarch64-apple-darwin) to see who pulls it.

Pick the lightest mitigation that fits:

  1. Drop the default feature that pulls it. If you only need a subset (e.g. git2 for local repository operations), set git2 = { version = "…", default-features = false } in the workspace-root Cargo.toml. This removes openssl-sys/libssh2-sys from every target, not just darwin, and is usually the cleanest fix. Watch for other deps re-enabling the feature — unification will bring it back.
  2. Vendor the C library. Enable the crate’s vendored feature (e.g. openssl’s vendored, libgit2-sys’s vendored-openssl) so the library is compiled from source with the cross toolchain instead of looked up as a system library.
  3. Provide a cross-built library. Build the library for the darwin target and hand it to the build via targetArgs.darwin-*.buildInputs plus the matching OPENSSL_DIR / PKG_CONFIG_* env. This is the most work — osxcross is not a nixpkgs pkgsCross, so there is no off-the-shelf cross package — and is rarely worth it when (1) or (2) apply.

Example

let
  toolchain = harbor-rs.lib.mkToolchain { inherit pkgs; };
  cross = harbor-rs.lib.mkCross { inherit pkgs system; };

  commonArgs = {
    inherit src;
    version = "1.0.0";
    strictDeps = true;
  };

  crossPkgs = harbor-rs.lib.mkCrossPackages {
    inherit pkgs cross commonArgs;
    inherit (toolchain) craneLib;
    pname = "my-app";
    targets = [ "native" "aarch64-linux" "windows" "darwin-aarch64" ];
    targetArgs = {
      native.buildInputs = [ pkgs.openssl ];
      aarch64-linux.buildInputs = [ cross.linuxAarch64.pkgsCross.openssl ];
      windows.buildInputs = with pkgs.pkgsCross.mingwW64; [ openssl windows.pthreads ];
    };
  };
in {
  packages = crossPkgs // { default = crossPkgs."my-app"; };
}

See mkCross for the cross toolchain inputs (windowsEnv, linuxAarch64, osxcrossRustHelpers) that this helper consumes.

Binary release archives

The v1-compatible mkBinaryRelease packages one or more already-built binaries into a deterministic, static MUSL archive. The archive contains bin/ and a manifest.json describing the package name, version, Nix system, Rust target, and exact binary list. mkReleaseBinaryPackage consumes an unpacked archive from a locked flake = false input and validates that contract before installing the binaries. Keep this schema when existing consumers already consume v1 archives; new multi-format release wiring should use the generic v2 constructors below.

The producer and consumer are deliberately separate. A project keeps its source-built package for development and exposes the prebuilt package as an explicit opt-in output:

let
  release = harbor-rs.lib.mkBinaryRelease {
    inherit pkgs;
    pname = "my-tool";
    version = "1.2.3";
    artifacts = {
      x86_64-linux-musl = {
        package = packages.my-tool-x86_64-linux-musl;
        system = "x86_64-linux";
        rustTarget = "x86_64-unknown-linux-musl";
        binaries = ["my-tool"];
      };
      aarch64-linux-musl = {
        package = packages.my-tool-aarch64-linux-musl;
        system = "aarch64-linux";
        rustTarget = "aarch64-unknown-linux-musl";
        binaries = ["my-tool"];
      };
    };
  };
in
{
  packages.release-bundle = release.releaseBundle;
}

A consumer pins each supported release asset as a non-flake input, verifies the published Simit checksum signature before changing its lock file, then selects the matching input:

inputs.my-tool-bin-x86_64.url =
  "https://codeberg.org/example/my-tool/releases/download/1.2.3/my-tool-1.2.3-x86_64-linux-musl.tar.gz";
inputs.my-tool-bin-x86_64.flake = false;

packages.my-tool-prebuilt = harbor-rs.lib.mkReleaseBinaryPackage {
  inherit pkgs;
  pname = "my-tool";
  version = "1.2.3";
  sources.x86_64-linux = inputs.my-tool-bin-x86_64;
  binaries = ["my-tool"];
};

The helper fails closed when the current system has no matching input or the archive manifest is inconsistent. The release workflow is generated by simit init release; it builds the bundle on atlas-nix-trusted, publishes the archives to Codeberg, and signs the checksum manifest with the committed keys/minisign.pub trust root.

Before the first tag, provision the generated workflow’s required secrets: CODEBERG_TOKEN at user scope, and a dedicated harbor-rs MINISIGN_SECRET_KEY/MINISIGN_PASSWORD pair at repository scope. The public key in keys/minisign.pub must be the matching key; do not reuse another project’s signing key. Check the contract with simit release secrets contract --json and the Canix declaration with canix release secrets check --project /path/to/harbor-rs.

Portable native applications

Applications that cannot satisfy the static ELF contract can use the pinned nix-bundle backend:

let
  release = harbor-rs.lib.mkPortableBinaryRelease {
    inherit pkgs;
    pname = "my-app";
    version = "1.2.3";
    artifacts.x86_64-linux.entries.my-app.package = packages.my-app;
  };
in {
  packages.release-bundle = release.releaseBundle;
}

The resulting archive contains a self-extracting executable and a v2 manifest.json with format = "nix-bundle". Consumers use mkPortableReleaseBinaryPackage with a locked flake = false archive. The bundle remains dependent on the host kernel and hardware interfaces (for example GPU drivers or PipeWire), which must be smoke-tested before switching the production module.

Generic release bundles

Projects with more than one release format can use the format-neutral constructors. mkReleaseArtifact exposes one flat file, mkReleaseArchive stages named files into a deterministic tar.gz or zip, and mkReleaseBundle combines those outputs and writes one versioned *-release-manifest.json. The configured bundle set is the explicit input consumed by Simit’s [release.artifacts].nix_bundle_attrs list and must produce exactly one versioned manifest; project-specific build_commands remain additive for formats that need extra signing or installer work.

let
  archive = harbor-rs.lib.mkReleaseArchive {
    inherit pkgs;
    pname = "my-tool";
    version = "1.2.3";
    name = "my-tool-1.2.3-x86_64-linux.tar.gz";
    package = packages.my-tool;
    entries = {"bin/my-tool" = "bin/my-tool";};
    system = "x86_64-linux";
    rustTarget = "x86_64-unknown-linux-gnu";
  };
in
{
  packages.release-bundle = harbor-rs.lib.mkReleaseBundle {
    inherit pkgs;
    pname = "my-tool";
    version = "1.2.3";
    artifacts = {inherit archive;};
  };
}

mkDevShell, mkDocsShell, and mkDevShells

mkDevShell builds one development shell. mkDocsShell builds a dedicated docs/tooling shell with the same base toolchain wiring but with Windows and macOS cross environment variables disabled by default. mkDevShells builds the default four-shell layout used by most downstream workspaces. All generated shells include cargo-sweep and the shared native build tools before project-specific packages are appended.

mkDevShell

Use mkDevShell when you want a single environment with precise control over Windows and macOS helpers.

Important parameters:

  • pkgs
  • craneLib
  • cross
  • enableWindowsEnv
  • enableOsxcrossEnv
  • pkgConfigDeps
  • packages
  • extraEnv
  • extraShellHook
  • checks
  • cargoConfig: optional override; defaults to the configuration attached by mkToolchain

mkProjectCliShellTools

Use mkProjectCliShellTools when a project wants its flake-built CLI available in direnv or nix develop:

projectCli = harbor-rs.lib.mkProjectCliShellTools {
  inherit pkgs;
  package = self'.packages.my-cli;
  commandName = "my-cli";
  hint = "my-cli dev shell - run `my-cli --help`";
  versionCheck.expected = version;
};

Append projectCli.packages to the shell packages and projectCli.shellHook to the shell hook. The hook fails if command -v resolves to a different binary than the package output, which prevents stale tools earlier on PATH from shadowing the current flake build.

mkDevShells

mkDevShells wraps mkDevShell and returns:

  • default
  • windows
  • macos
  • cross

That layout works well for workspaces where some crates only need native tools while others need MinGW or osxcross.

mkDocsShell

Use mkDocsShell for CI and local workflows that only need mdBook documentation:

  • nix develop .#docs -c cargo doc --no-deps --all-features
  • nix develop .#docs -c mdbook serve docs

The Plinth-powered project site is intentionally isolated from the reusable harbor-rs flake so consumers cannot create a dependency cycle. Run its dedicated nested flake when needed:

nix develop ./site#docs
nix build ./site#site

Example

devShells =
  (harbor-rs.lib.mkDevShells {
    inherit pkgs cross;
    inherit (toolchain) craneLib;
    packages = with pkgs; [ just vulkan-loader ];
    pkgConfigDeps = with pkgs; [ wayland libxkbcommon udev alsa-lib ];
    extraEnv = {
      LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.vulkan-loader ];
    };
    extraShellHook = ''
      echo "Welcome to my project!"
    '';
  };
  })
  // {
    docs = harbor-rs.lib.mkDocsShell {
      inherit pkgs cross;
      inherit (toolchain) craneLib;
      packages = with pkgs; [ mdbook ];
    };
  };

The shell installs the selected configuration in a hash-specific directory under the user cache and uses it as CARGO_HOME only when the caller has not already selected one. Pass cargoConfig explicitly only to override the configuration attached to craneLib by mkToolchain. Prefer toolchain.cargoConfig over reconstructing the same file with mkCargoConfig.

Activation

Harbor produces shell packages, environment variables, and hooks. It does not activate a repository. Each project chooses its toolchain and direnv policy.

This .envrc pattern requires nix-direnv. Register source watches before use flake. Disable stale-cache fallback so a failed rebuild is not reported as success. Do not update lockfiles from direnv.

watch_file flake.nix flake.lock
nix_direnv_disallow_fallback
use flake . --no-update-lock-file || return 1

A nested checkout that must not inherit a parent Harbor shell needs its own .envrc: either use flake . for that project, or a no-op file such as true. An absent .envrc inherits the parent.

Harbor’s Cargo hook:

  • installs generated config only into Harbor’s hashed cache directory
  • leaves an existing CARGO_HOME unchanged and reports that generated config is not activated
  • fails the shell when that install cannot complete

checks.mkDevShells-cargo-home covers repeated, concurrent, user-owned, and failed-install cases. lib.devShellTests.mkCheck covers executable availability. Real direnv transitions remain a consumer check.

mkGpuRenderPin

mkGpuRenderPin returns a devShell-ready GPU render profile for projects whose visual snapshots depend on a stable renderer and driver. It is intended for test harnesses that compare pixels or structural image similarity and need to fail fast when a developer is using a different GPU stack than the baseline author.

Usage

visualTestGpuPin = harbor-rs.lib.mkGpuRenderPin {
  inherit pkgs;
  profile = "mesa-radv";
};

devShells = harbor-rs.lib.mkDevShells {
  inherit pkgs cross;
  inherit (toolchain) craneLib;

  packages = with pkgs; [ just ] ++ visualTestGpuPin.packages;
  extraEnv = visualTestGpuPin.env // {
    LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
      pkgs.vulkan-loader
      pkgs.libxkbcommon
    ];
  };
};

The consuming test framework should read the exported RS_HARBOR_GPU_* variables and compare them with the renderer identity reported by the graphics API it actually initialized.

Parameters

ParamDefaultDescription
pkgsrequirednixpkgs package set used to provide the graphics stack
profile"mesa-radv"Named render profile
expected{}Extra or overriding expected identity fields. Attributes with null values are ignored

Returns: { env, packages }

Profiles

mesa-radv

Pins visual rendering to Vulkan on Mesa RADV:

  • RS_HARBOR_GPU_PIN_PROFILE=mesa-radv
  • RS_HARBOR_GPU_EXPECTED_BACKEND=Vulkan
  • RS_HARBOR_GPU_EXPECTED_VENDOR=0x1002
  • RS_HARBOR_GPU_EXPECTED_DRIVER_CONTAINS=radv
  • WGPU_BACKEND=vulkan
  • VK_DRIVER_FILES=<pkgs.mesa>/share/vulkan/icd.d/radeon_icd.<system>.json

The profile intentionally checks the AMD vendor and RADV driver family without pinning a specific GPU device ID. That keeps one baseline usable across AMD machines while still rejecting NVIDIA, Intel, lavapipe, llvmpipe, Metal, DX12, and GL renderers for that baseline.

Supported RADV ICD names are currently defined for x86_64-linux, i686-linux, and aarch64-linux.

Expected Identity Contract

Consumers may use these environment variables:

  • RS_HARBOR_GPU_PIN_PROFILE
  • RS_HARBOR_GPU_EXPECTED_BACKEND
  • RS_HARBOR_GPU_EXPECTED_VENDOR
  • RS_HARBOR_GPU_EXPECTED_DEVICE
  • RS_HARBOR_GPU_EXPECTED_DEVICE_TYPE
  • RS_HARBOR_GPU_EXPECTED_NAME_CONTAINS
  • RS_HARBOR_GPU_EXPECTED_DRIVER_CONTAINS
  • RS_HARBOR_GPU_EXPECTED_DRIVER_INFO_CONTAINS

mkGpuRenderPin sets only the fields that are meaningful for the selected profile. Downstream projects can add stricter checks with expected, for example:

harbor-rs.lib.mkGpuRenderPin {
  inherit pkgs;
  profile = "mesa-radv";
  expected.RS_HARBOR_GPU_EXPECTED_DEVICE_TYPE = "DiscreteGpu";
}

harbor-ci

harbor-ci provides a small, reproducible Cargo quality gate for Rust workspaces. It reads [workspace.metadata.harbor-ci] (or the equivalent package metadata) from Cargo.toml and runs the selected profile:

harbor-ci fast
harbor-ci default
harbor-ci full --report target/harbor-ci.json

The metadata supports package selection and workspace exclusions:

[workspace.metadata.harbor-ci]
test-runner = "nextest"
exclude = ["integration-tests"]
nextest-args = ["--profile", "ci", "-E", "not binary(intheavy_*)"]

exclude is emitted as Cargo’s repeated --exclude option for workspace Cargo stages and cannot be combined with packages. The formatting stage remains workspace-wide because rustfmt does not support Cargo package exclusions. nextest-args is passed as individual arguments to the nextest stage and requires test-runner = "nextest"; it is never parsed as shell text.

Project-specific feature matrices, service provisioning, and compile-only checks should remain in the project’s own CI command. harbor-ci is intended to cover the uniform Cargo gate, not become a second workflow language.

mkAppImage

mkAppImage wraps a built Linux executable into a self-contained AppImage.

Notes

  • Linux only
  • Opt-in: you must add nix-appimage to your own flake inputs
  • The result is a derivation that emits a .AppImage file

Example

packages.appimage = harbor-rs.lib.mkAppImage {
  inherit system nix-appimage;
  program = "${myPackage}/bin/my-app";
};

Use this when you already have a reproducible Nix build and want a portable desktop artifact on top of it.

mkFlatpakManifest

mkFlatpakManifest generates a Flatpak manifest JSON file for packaging a pre-built application with flatpak-builder.

Parameters

  • pkgs
  • appId
  • pname
  • desktopFile
  • icon
  • runtime
  • runtimeVersion
  • sdk
  • sdkExtensions
  • finishArgs

Example

packages.flatpak-manifest = (harbor-rs.lib.mkFlatpakManifest {
  inherit pkgs;
  appId = "com.example.MyApp";
  pname = "my-app";
  desktopFile = ''
    [Desktop Entry]
    Type=Application
    Name=My App
    Exec=my-app
    Icon=com.example.MyApp
    Categories=Utility;
  '';
}).manifestPath;

This is useful when your project already builds the binary in Nix, but you want Flatpak metadata generated from the same source of truth.

mkCoprSpec

mkCoprSpec generates a Fedora RPM .spec for packaging via COPR, Fedora’s community build service. harbor-rs only produces the spec — uploading or building it stays in CI (or a shell), where the COPR credentials live.

The defaults assume the “binary-shipping” workflow: a binary already built in Nix is bundled as the Source0 tarball, then installed to %{_bindir} by the generated %install section.

Parameters

  • pkgs
  • name
  • version — must not contain - (RPM forbids it in Version:)
  • release — defaults to 1%{?dist}
  • summary
  • license
  • url
  • sources — list of Source* lines; defaults to Source0: %{name}-%{version}.tar.gz
  • buildArch — e.g. "x86_64" or "noarch"; omitted by default
  • buildRequires
  • requires
  • description — defaults to summary
  • prep, build, install, files — override the generated section bodies
  • desktopFile, icon, appId — if appId (reverse-DNS) is set, the default %install and %files add ${appId}.desktop and a 256x256 hicolor icon
  • changelog — list of { date; author; version; entries; }
  • coprMakefile — when true, also emit a .copr/Makefile for COPR’s custom-build SCM method
  • extraSections — appended raw before %changelog

Example

packages.copr-spec = (harbor-rs.lib.mkCoprSpec {
  inherit pkgs;
  name = "my-app";
  version = "1.0.0";
  summary = "My example application";
  license = "MIT";
  url = "https://example.com/my-app";
  appId = "com.example.MyApp";
  desktopFile = ''
    [Desktop Entry]
    Type=Application
    Name=My App
    Exec=my-app
    Icon=com.example.MyApp
    Categories=Utility;
  '';
  changelog = [
    {
      date = "Tue May 19 2026";
      author = "Can <can@example.com>";
      version = "1.0.0-1";
      entries = ["Initial COPR release"];
    }
  ];
}).specPath;

Workflow

nix build .#my-app
nix build .#copr-spec
tar czf my-app-1.0.0.tar.gz -C result/bin my-app
copr-cli build my-project ./result

For a pre-built binary, you usually want to disable COPR’s debuginfo extraction. Pass it through extraSections:

extraSections = "%global debug_package %{nil}";

If you’d rather have COPR drive the build itself from your git repo, set coprMakefile = true and commit the generated .copr/Makefile plus the .spec — then point a COPR “Custom” build at the repo.

mkHomebrewFormula

mkHomebrewFormula generates a Homebrew formula (.rb) for packaging pre-built binary release archives. At Nix evaluation time, use the ":no_check" placeholder when the archives do not exist yet. After the release archives are built, use harbor-rs brew bump to compute real sha256 sums and update the tap.

The helper renders OS and CPU-specific downloads as nested Homebrew platform blocks, such as on_macos do with on_arm do or on_intel do inside it. A sha256 value of ":no_check" is emitted as Homebrew’s sha256 :no_check symbol for workflows that fill checksums later.

Parameters

  • pkgs
  • name — Homebrew formula name, e.g. modde
  • version — release version without a leading v
  • description — one-line description, 80 characters or fewer
  • homepage — HTTPS project URL
  • license — SPDX license identifier
  • platforms — attrset keyed by darwin_arm, darwin_intel, linux_arm, or linux_intel; each value is { url; sha256; }
  • dependencies — list of Homebrew formula dependencies
  • binaries — list of binaries installed into bin
  • caveats — optional post-install message
  • testBlock — optional Ruby body for test do
  • extraRubyBody — optional raw Ruby appended to the formula body

Example

packages.homebrew-formula = (harbor-rs.lib.mkHomebrewFormula {
  inherit pkgs;
  name = "my-app";
  version = "1.0.0";
  description = "My example application";
  homepage = "https://example.com/my-app";
  license = "MIT";
  platforms = {
    darwin_arm = {
      url = "https://example.com/releases/my-app-1.0.0-aarch64-darwin.tar.gz";
      sha256 = ":no_check";
    };
    darwin_intel = {
      url = "https://example.com/releases/my-app-1.0.0-x86_64-darwin.tar.gz";
      sha256 = ":no_check";
    };
  };
  dependencies = ["openssl@3"];
  binaries = ["my-app"];
  testBlock = ''system "#{bin}/my-app", "--version"'';
}).formulaPath;

The returned attrset also includes formulaText for inspection or for downstream tools that need to rewrite placeholders before publishing.

Homebrew CLI

harbor-rs brew bump renders a Homebrew formula from release metadata and archive files that already exist on disk. It computes sha256 sums from the local archive paths and writes a ready-to-commit formula to a tap repository.

The archive URL is embedded in the formula, while the archive path is only used for hashing. The URL must point at the same bytes as the local path; otherwise brew install will fail with a checksum mismatch.

Example

harbor-rs brew bump \
  --tap ../homebrew-tap \
  --name my-app \
  --version 1.0.0 \
  --description "My example application" \
  --homepage https://example.com/my-app \
  --license MIT \
  --depends openssl@3 \
  --binary my-app \
  --archive darwin_arm=https://example.com/releases/my-app-1.0.0-aarch64-darwin.tar.gz,dist/my-app-darwin-arm.tar.gz \
  --archive linux_intel=https://example.com/releases/my-app-1.0.0-x86_64-linux.tar.gz,dist/my-app-linux-intel.tar.gz

Use --stdout instead of --tap to print the formula for review or piping:

harbor-rs brew bump --stdout \
  --name my-app \
  --version 1.0.0 \
  --description "My example application" \
  --homepage https://example.com/my-app \
  --license MIT \
  --archive linux_intel=https://example.com/my-app.tar.gz,dist/my-app.tar.gz

Use --push to run git add, git commit, and git push in the tap repository. This mode is default-off, requires --tap, and aborts if the tap has dirty files other than Formula/<name>.rb. Git credentials are the caller’s responsibility.

Dioxus packages

harbor-rs.lib.mkDioxusWebPackage and harbor-rs.lib.mkDioxusFullstackPackage provide the shared Nix mechanics for Dioxus 0.7 applications. They vendor Cargo dependencies, resolve the exact wasm-bindgen-cli version recorded in Cargo.lock, add the native linker tools needed by fullstack builds, and run the Dioxus CLI offline.

Web package

Use the web builder when the product already owns the server process or static asset routing:

harbor-rs.lib.mkDioxusWebPackage {
  inherit pkgs craneLib rustToolchain;
  src = filteredSource;
  cargoLock = ./Cargo.lock;
  pname = "my-app-dioxus";
  package = "my-app";
  wasmBindgenCli = exactWasmBindgenCli;
  webFeatures = [ "web" ];
  wasmSplit = true;
  installSubdir = "share/my-app/dioxus";
}

The derivation installs the generated Dioxus public/ tree below installSubdir (including index.html, hashed JavaScript, and WASM assets). The product can copy that tree into its own static directory and apply its own compression or cache policy.

Release plans pass --debug-symbols=false explicitly. Dioxus 0.7 otherwise retains DWARF even for --release, increasing the browser artifact and making some Binaryen releases abort while re-emitting debug information. Non-release profiles keep symbols by default; set debugSymbols explicitly when a product needs the opposite policy. The builder also turns an ignored wasm-opt failure back into a failed Nix build, preventing an unoptimized fallback bundle from reaching a release.

Fullstack package

Use the fullstack builder when Dioxus owns the deployable server executable:

harbor-rs.lib.mkDioxusFullstackPackage {
  inherit pkgs craneLib rustToolchain;
  src = filteredSource;
  cargoLock = ./Cargo.lock;
  pname = "my-app";
  package = "my-app";
  wasmBindgenCli = exactWasmBindgenCli;
  webFeatures = [ "web" ];
  serverFeatures = [ "server" ];
  publicSubdir = "share/my-app/public";
}

The output contains bin/my-app and bin/my-app-unwrapped, plus the generated public tree. The wrapper sets DIOXUS_PUBLIC_PATH to the packaged public path; set wrapServer = false when the product supplies its own process wrapper.

Externally built servers

Some applications own an Axum or other server executable that embeds Dioxus SSR while building the browser bundle separately. Add the asset linker to that server derivation’s native build inputs and run it before wrapping or stripping the executable:

let
  dioxusAssetLinker = harbor-rs.lib.mkDioxusAssetLinker {
    inherit pkgs;
    dioxusCli = pkgs.dioxus-cli;
  };
in
craneLib.buildPackage {
  nativeBuildInputs = [ dioxusAssetLinker ];
  postInstall = ''
    dioxus-link-assets \
      "$out/bin/my-server" \
      "$out/share/my-app/public/assets"
  '';
}

This runs Dioxus’ own dx tools assets command, which copies declared assets and patches their hashed paths into the executable. Crane consumers must call it in the derivation’s postInstall, before reference-removal hooks sanitize vendored source paths.

Toolchain and feature policy

mkDioxusBuildPlan is available for consumers that need to inspect or compose the command shape. Fullstack plans build the client with @client and the server with @server --server, allowing independent Cargo feature and target arguments. resolveWasmBindgenCli fails early when the lockfile version has no exact nixpkgs package; callers may pass a custom derivation with the matching .version instead. mkDioxusPackage is retained as a compatibility alias for the web builder during the migration window.

Android APK packages

Android SDK, APK, and Maven-cache helpers live in harbor-android.

harbor-rs.lib re-exports mkAndroidSdk, mkAndroidDevShell, mkAndroidApk, mkAndroidApkDevBuilder, mkAndroidFlavorTable, and findLocalMavenCache for one migration release. New consumers should take harbor-android directly.

Keep mkToolchain here and pass rustToolchain into the Android helpers. mkGradlePackage and fetchMavenCache also stay in harbor-rs.

Package Tests

harbor-rs exposes package build and package-test plans through harbor-meta so downstream flakes can describe package verification consistently before each package format has a runnable backend.

The hierarchy is:

  • artifact builders for package outputs
  • generic package test plans for any package artifact
  • Windows package test plans for PowerShell-based installers
  • runner builders for local test environments
  • Chocolatey Vagrant plans backed by the community Chocolatey test environment

Existing package helpers expose an artifactBuilder field on their result where they have a concrete output: mkAppImage, mkDebPackage, mkCoprSpec, mkFlatpakManifest, mkHomebrewFormula, mkScoopManifest, mkChocoPackage, mkAndroidApk, mkAndroidApkDevBuilder, and mkTrunkPackage. Only Chocolatey has a runnable VM helper today. Other package formats can still emit normalized plans with unsupportedRunnerReason so release tooling can surface the missing local backend explicitly.

Chocolatey

let
  choco = harbor-rs.lib.mkChocoPackage {
    inherit pkgs;
    id = "my-app";
    version = "1.0.0";
    description = "Example Windows CLI";
    homepage = "https://example.com";
    license = "MIT";
    licenseUrl = "https://example.com/LICENSE";
    authors = ["Example"];
    architectures.x64 = {
      url = "https://example.com/my-app-1.0.0.zip";
      sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    };
  };

  chocoTest = harbor-rs.lib.mkChocoTestEnvironment {
    inherit pkgs;
    chocoPackage = choco;
    verifyPowerShell = [''
      my-app --version
    ''];
  };
in {
  packages.choco = choco.nupkgPath;
  apps.choco-test = chocoTest.app;
}

mkChocoPackage exposes artifactBuilder, with output pointing at the generated .nupkg. mkChocoTestEnvironment carries that builder into its test plan and returns a runnerBuilder for the generated Vagrant environment.

apps.choco-test writes a local .package-test/<package> directory, stages the .nupkg under packages/, writes a generated Vagrantfile, and then runs the Chocolatey install inside the Vagrant VM.

The generated Vagrantfile uses the chocolatey/test-environment box, syncs packages to C:\packages, and defaults to verifier-like VirtualBox settings: headless, 4 CPUs, 6144 MiB RAM, and disabled clipboard/drag-and-drop.

Generic Plans

Use mkPackageArtifactBuilder and mkPackageTestPlan when a package format does not yet have a runner:

let
  deb = harbor-rs.lib.mkDebPackage {
    inherit pkgs;
    packageName = "my-app";
    version = "1.0.0";
    arch = "amd64";
    maintainer = "Example <example@example.com>";
    description = "Example CLI";
    files = [
      {
        source = "${self.packages.${system}.my-app}/bin/my-app";
        target = "/usr/bin/my-app";
        mode = "0755";
      }
    ];
  };
in harbor-rs.lib.mkPackageTestPlan {
  inherit pkgs;
  kind = "debian";
  artifactBuilder = deb.artifactBuilder;
  installCommand = "apt install ./my-app.deb";
  unsupportedRunnerReason = "No generic Debian VM runner is implemented yet.";
}

Use mkPackageArtifactBuilder directly when a downstream packaging helper is not implemented in harbor-rs yet but still has a concrete output path.

Steam Runtime Tools

mkSteamRuntimeTools provides generic helpers for Rust games that ship native Linux builds on Steam and want explicit dynamic-linking audits.

The helpers deliberately avoid app-specific policy. Your project still owns Steam app IDs, depot layout, release branches, crash handlers, symbols, and the actual Cargo or engine build command.

Basic Usage

steamRuntimeTools = harbor-rs.lib.mkSteamRuntimeTools {
  inherit pkgs;
};

The default runtime is Steam Linux Runtime 3.0 sniper:

steamRuntimeTools.image
# "registry.gitlab.steamos.cloud/steamrt/sniper/sdk"

You can select another known runtime or provide a custom OCI image:

steamRuntimeTools = harbor-rs.lib.mkSteamRuntimeTools {
  inherit pkgs;
  runtime = "scout";
};
steamRuntimeTools = harbor-rs.lib.mkSteamRuntimeTools {
  inherit pkgs;
  runtime = "my-runtime";
  customImage = "registry.example.com/my/steam-sdk:latest";
};

Flake Apps

Expose the tools as apps so just, CI, and release scripts can call them:

apps = {
  steam-runtime-exec = {
    type = "app";
    program = "${steamRuntimeTools.steamRuntimeExec}/bin/steam-runtime-exec";
  };

  audit-elf-runtime-deps = {
    type = "app";
    program = "${steamRuntimeTools.auditElfRuntimeDeps}/bin/audit-elf-runtime-deps";
  };
};

Running Commands In The SDK Container

steam-runtime-exec runs a command in the selected SDK image, mounting the current working directory at the same path:

nix run .#steam-runtime-exec -- -- cargo build --release

The wrapper does not install Rust, Nix, engine dependencies, or Steamworks SDK content. Projects with custom toolchains should either bake those into their own image or use the wrapper for runtime validation commands.

For Nix-built audit tools, mount /nix/store read-only:

nix run .#steam-runtime-exec -- --mount-nix-store -- \
  /nix/store/.../bin/audit-elf-runtime-deps dist/linux

Dependency Audits

The audit scripts are intentionally allowlist-driven:

nix run .#audit-elf-runtime-deps -- \
  --require-origin-rpath \
  --allow-needed-regex '^(lib(c|m|dl|pthread|gcc_s|stdc\+\+|steam_api).*)\.so(\..*)?$' \
  dist/linux

Windows and macOS use the same pattern:

nix run .#audit-windows-runtime-deps -- \
  --allow-dll-regex '^(KERNEL32|USER32|ADVAPI32|WS2_32|steam_api64)\.dll$' \
  dist/windows
nix run .#audit-darwin-runtime-deps -- \
  --allow-dylib-regex '^(@executable_path|@rpath|/usr/lib/|/System/Library/)' \
  dist/macos

For Linux Steam release candidates, run the ELF audit inside the same Steam Runtime container that the Steamworks launch option selects.

mkAdapter

mkAdapter builds a typed harbor-adapter value that describes how a downstream project should talk to an Attic binary cache.

Example

harborAdapter = harbor-rs.lib.mkAdapter {
  attic = {
    endpoint = "https://cache.example.com";
    cache = "main";
  };
};

The adapter includes:

  • cache endpoint
  • cache name
  • token environment variable, defaulting to ATTIC_TOKEN
  • a small type marker so mkAtticPush can validate what it receives

Use mkAtticPush to turn the adapter into a runnable Nix app.

mkAtticPush

mkAtticPush creates a Nix app that pushes store paths to Attic using an ephemeral credential file. It can publish explicit paths, optionally including their closures, and every recursively locked source path returned by nix flake archive.

Example

apps.push-cache = harbor-rs.lib.mkAtticPush {
  inherit pkgs;
  adapter = infra.harborAdapter;
  paths = [ self.packages.${system}.default ];
};

apps.push-flake-inputs = harbor-rs.lib.mkAtticPush {
  inherit pkgs;
  adapter = infra.harborAdapter;
  flake = ".";
};

Run it with:

nix run .#push-cache
nix run .#push-flake-inputs

The generated script expects the token environment variable described by the adapter, defaulting to ATTIC_TOKEN. It never runs attic login; its mode-0600 Attic configuration is removed on exit. Set HARBOR_ATTIC_MANIFEST to retain the sorted path manifest outside the temporary directory.

Bevy Template

harbor-rs exports a bevy flake template for new game projects.

Initialize from the template

nix flake init -t git+https://github.com/caniko/harbor-rs.git#bevy

What the template includes

  • mkToolchain, mkCross, and mkCargoConfig wired into the project flake
  • mkDevShells for native, Windows, macOS, and combined cross shells
  • commented examples for mkAppImage and mkFlatpakManifest
  • Bevy-specific shell dependencies in nix/dev-shells.nix

If you want to understand the generated shell behavior in more detail, read mkDevShell and mkDevShells.

macOS SDK Initialization

For version-controlled project flakes, harbor-rs supports an explicit macosSdkStorePath, but that path is host-specific. Reusable flakes should accept it from host configuration instead of committing it directly. harbor-rs owns SDK discovery and validation; osxcross receives only the resolved SDK reference.

Initialize once per host

nix run harbor-rs#init-macos-sdk -- /host/local/MacOSX26.1.sdk.tar.xz 26.1

The command realizes the archive, validates the SDK root, and prints:

  • the host-specific store path for host configuration
  • the resolved SDK root
  • the recursive hash used for the fixed-output rebuild

Validation requires:

  • SDKSettings.json
  • usr/include/TargetConditionals.h
  • System/Library/Frameworks
  • SystemConfiguration.framework
  • CoreFoundation.framework

Inject the printed store path

canix.development.macosSdk.storePath = "/nix/store/<host-sdk>-macosx-sdk-26.1";
canix.development.macosSdk.sdkVersion = "26.1";

Host wrappers can then pass the value to mkCross as macosSdkStorePath. Standalone project flakes should keep it null so native and non-macOS cross outputs evaluate everywhere. On another machine, either run the same initialization flow with that host’s local archive or fetch the realized store path from your binary cache.

Always pass macosSdkOutputHash too (the sandbox gotcha)

A bare macosSdkStorePath is a string with no Nix string context, so on its own it is not a build input. Under sandbox = true the daemon never bind-mounts it and osxcross-clang fails inside the sandboxed build with cannot find macOS SDK, even though the path is valid in the store. Passing only macosSdkStorePath silently produces this broken state — there is no error at eval time.

Always pass macosSdkOutputHash alongside it (the recursive hash printed by init-macos-sdk):

cross = harbor-rs.lib.mkCross {
  inherit pkgs system;
  macosSdkStorePath = "/nix/store/<host-sdk>-macosx-sdk-26.1";
  macosSdkOutputHash = "sha256-…";   # recursive hash from init-macos-sdk
  osxSdkVersion = "26.1";
};

With the hash, harbor-rs reconstructs the SDK’s fixed-output derivation. Its output path is fully determined by (name, outputHash, recursive sha256), so it resolves to the identical store path — but now with context, making the SDK a real, sandbox-mounted dependency. harbor-rs asserts the reconstructed path equals macosSdkStorePath, so a hash/version mismatch fails loudly at eval instead of silently. Already-realized paths are used directly (no rebuild); a missing, unsubstitutable SDK fails early with a clear message.

Because the reconstruction is a recursive-SHA256 fixed-output derivation, its output is content-addressed and signature-exempt: any substituter serving it (e.g. a private Attic cache) can provide it without a trusted signature. The substituter URL must still be in the daemon’s effective substituters list, though — a flake nixConfig.extra-substituters only takes effect with --accept-flake-config or for a trusted user.

Ad-hoc signing darwin binaries

The osxcross toolchain installs an unprefixed codesign_allocate on PATH (next to the arch-prefixed <arch>-apple-<target>-codesign_allocate), mirroring macOS’s /usr/bin/codesign_allocate. Tools that ad-hoc sign Mach-O outputs — e.g. sigtool’s codesign --sign - --force in a postInstall — spawn codesign_allocate by bare name. With the osxcross toolchain in nativeBuildInputs (as mkCrossPackages and the darwin cross-builders arrange), that bare-name spawn resolves on PATH, so no CODESIGN_ALLOCATE wiring is needed. Sign in postInstall, before fixupPhase’s strip leaves the signature in place.

Local discovery with MACOS_SDK

For local impure workflows, mkCross can read MACOS_SDK when enableImpureMacosSdkEnv = true. The value may be:

  • a direct SDK root, such as /path/to/MacOSX26.1.sdk
  • a parent directory containing MacOSX26.1.sdk
  • a supported SDK archive, such as /path/to/MacOSX26.1.sdk.tar.xz

Explicit arguments take precedence over MACOS_SDK: macosSdk, macosSdkStorePath, sdkArchive, then the environment value.