Git hashes in Nix
While building Amp, I'm often making small, incremental changes in-between official releases. To dogfood these changes during my day-to-day work, I've added the project as a git-based input to my NixOS flake config, allowing me to easily pull those changes into my local installation.
Across various machines, it can be hard to tell which version I'm running. To fix that, I added a "build revision" to the splash screen, showing the shorthand commit hash of the current build:
Populating that during Nix-based builds ended up being tricker than I'd thought.
Solving in Rust
If I'm building Amp locally from a cloned repo, this is pretty easy. In that scenario, it's safe to assume Git is installed and available in the user environment, and you can shell out in the Cargo build script:
fn set_build_revision() {
// Run the Git command to get the current commit hash
let output = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.expect("Failed to execute git command");
// Parse the hash
let build_revision = String::from_utf8(output.stdout)
.expect("Invalid UTF-8 sequence");
// Write the hash as an environment variable
println!("cargo:rustc-env=BUILD_REVISION={}", build_revision.trim());
}
This produces an environment variable that's handed off to the compile phase,
where it can be interpolated in the splash screen source using the env!
macro:
There's one problem with this approach: it doesn't work with Nix.
Nix is adamant that builds are pure and don't include local state. If you've ever been caught by Nix ignoring files that aren't tracked by source control, or you've encountered messages like this, you know what I'm talking about:
What you might not be aware of is that Nix also removes the .git
directory
during builds. As you can imagine, this breaks the hash look-up command in
the build script above. To fix this, we need to push that responsibility out of
the Rust build and into the Nix config.
Ackshually...
Nix doesn't always remove the .git
directory. If you're writing a Nix
build script that lives outside the repository it's building, you're likely
using a Git-based
fetcher, which can be
configured with a leaveDotGit
option that will preserve the .git
directory. That's not applicable here, but it's worth calling out.
Solving in Nix
Making this work in a Nix environment requires two changes:
- a Nix build configuration that sets the
BUILD_REVISION
environment variable - a Rust build script that leaves that environment variable alone if it's already set
Let's get the Rust build script change out of the way first.
Don't shell out in Rust
The git rev-parse --short HEAD
look-up in the Rust build script shouldn't be
used when building with Nix. To make that optional, we can update the
build_revision
function we created to return early if a value is already set:
function build_revision() {
// Skip if the environment variable is already set
let revision = env::var("BUILD_REVISION");
if revision.map(|r| !r.is_empty()) == Ok(true) { // (1)!
return;
}
// Run the Git command to get the current commit hash
let output = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.expect("Failed to execute git command");
// Parse the hash
let build_revision = String::from_utf8(output.stdout)
.expect("Invalid UTF-8 sequence");
// Write the hash as an environment variable
println!("cargo:rustc-env=BUILD_REVISION={}", build_revision.trim());
}
- This is dense and hard to read. If you're curious: reading the environment
variable can fail, and so it returns a
Result
. Since we are ultimately checking for the presence of an environment variable, we only care if this look-up is successful and that its associated value is not empty. To do that, wemap
itsOk
variant to a boolean representing whether or not it has content, and then match against that.
Pretty straight-forward; now let's make the changes in Nix.
Nix hashes
To get a reference to the current revision in Nix, we'll use a special
self.rev
value available in flake outputs. Here's what that looks like in
an abridged version of the Rust project's flake.nix
:
{
description = "Amp: A complete text editor for your terminal";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in {
# Define packages for all supported systems
packages = forAllSystems (system: {
default = self.buildPackage { inherit system; };
});
# Function to build the package for a given system
buildPackage = { system }:
let pkgs = import nixpkgs { inherit system; };
in pkgs.rustPlatform.buildRustPackage {
pname = "amp";
version = "0.7.0";
cargoLock.lockFile = ./Cargo.lock;
# Use source files without version control noise
src = pkgs.lib.cleanSource ./.;
# Packages needed at runtime
buildInputs = with pkgs; [ git xorg.libxcb openssl zlib ];
# Packages needed during the build
nativeBuildInputs = with pkgs; [ git ];
# Make the build/check/install commands explicit so we can
# provide the commit SHA for the splash screen
buildPhase = ''
export BUILD_REVISION=${builtins.substring 0 7 (
if self ? rev then self.rev else ""
)}
cargo build --release
'';
checkPhase = ''
cargo test
'';
installPhase = ''
mkdir -p $out/bin
cp target/release/amp $out/bin/
'';
# Amp creates files and directories in $HOME/.config/amp when run.
# Since the checkPhase of the build process runs the test suite, we
# need a writable location to avoid permission error test failures.
HOME="$src";
};
};
}
There are a few implementation details here worth covering.
Taking a substring
This one is pretty straight-forward: the revision we're given by Nix is the full
Git SHA. We've written the Rust build script to take BUILD_REVISION
as-is, so
it's on us to trim that in the flake. Grabbing the first 7 characters gives us
the equivalent of a shorthand Git SHA.
Guarding against a missing revision
When a flake is declared as a dependency of another (e.g. adding a 3rd-party
flake to your NixOS flake inputs), Nix tracks its version in a flake.lock
file
so it's able to deterministically reproduce that build in the future.
The self.rev
attribute we're relying on is supported by this behaviour, which
means it's not available when the 3rd-party flake isn't being built through
another flake. As a result, we can't always rely on it being available, hence
the if/then/else
above.
Putting it all together
Getting Git hashes during a build isn't as easy as it sounds. To recap, here's how the build plays out in different scenarios:
build scenario | environment | build.rs result |
---|---|---|
outside of Nix | no BUILD_REVISION set |
Git-based look-up |
nix develop |
no flake.lock or self.rev , BUILD_REVISION is blank |
Git-based look-up |
from another flake | flake.lock and self.rev exist, BUILD_REVISION is set |
BUILD_REVISION |
Taking a layered approach as described here ensures there's a Git hash available in all build scenarios.