Language ecosystems (part A)
Language ecosystems (part A)
Goal: Package a real tool in your primary language using the nixpkgs language builder (buildGoModule, buildRustPackage, buildPythonApplication, …)—not raw stdenv unless the ecosystem forces it.
Pick one primary ecosystem in this chapter. Part B is a second, smaller transfer exercise. Depth beats collecting half-broken scaffolds for five languages.
Why it matters
stdenv taught phases. Production packaging is almost always ecosystem helpers: they know how to vendor deps, set LDFLAGS, wrap interpreters, and place modules. Mastering one helper end-to-end is how packaging craft becomes useful at work.
Raw stdenv for a Go module graph is possible and miserable. Use the builder; understand that it is still phases + FODs underneath.
Theory 1 — How language builders relate to stdenv
buildGoModule / buildRustPackage / buildPythonApplication / …
│
▼
stdenv.mkDerivation (+ setup hooks)
│
▼
phases + FODs (module vendor hashes, cargoLock, …)
You still have pname, version, meta, patches, and hooks—but fetching language deps is specialized.
| Ecosystem | Primary builder (nixpkgs) | Extra hash / lock concept |
|---|---|---|
| Go | buildGoModule |
vendorHash |
| Rust | rustPlatform.buildRustPackage |
cargoHash / cargoLock |
| Python | buildPythonApplication / buildPythonPackage |
pyproject / fetchPypi / overrides |
| Node | buildNpmPackage / importNpmLock |
npmDepsHash |
| Zig | buildZigPackage (where available) / stdenv |
Fetch deps carefully |
| C/C++ | stdenv + cmake/meson |
Usually no vendor hash |
Theory 2 — Go path (buildGoModule)
{ lib, buildGoModule, fetchFromGitHub }:
buildGoModule rec {
pname = "hello-go";
version = "0.1.0";
src = fetchFromGitHub {
owner = "you";
repo = "hello-go";
rev = "v${version}";
hash = "sha256-…";
};
vendorHash = "sha256-…"; # FOD of the vendored module graph
ldflags = [
"-s" "-w"
"-X main.version=${version}"
];
meta = {
description = "Tiny Go greeter";
license = lib.licenses.mit;
mainProgram = "hello-go";
};
}Local module (lab-friendly)
{ lib, buildGoModule }:
buildGoModule {
pname = "hello-go";
version = "0.1.0";
src = ../src/hello-go;
vendorHash = null; # only if no external modules
meta = {
mainProgram = "hello-go";
license = lib.licenses.mit;
};
}If you import modules, use a real vendorHash.
vendorHash workflow
- Set
vendorHash = lib.fakeHash;(ornullonly if truly module-free).
- Build; read
got:hash.
- Paste; rebuild.
- When
go.mod/go.sumchange, vendorHash changes.
nix build .#hello-go -LUseful knobs
| Attr | Purpose |
|---|---|
subPackages |
Build only ./cmd/foo |
proxyVendor |
Vendor via module proxy inside FOD |
doCheck = false |
Skip tests if network-bound (prefer fixing tests) |
env.CGO_ENABLED = "0" |
Pure Go when appropriate |
ldflags |
Version injection, strip |
tags |
Build tags |
modRoot |
Multi-module monorepos |
Example src/hello-go
// src/hello-go/main.go
package main
import (
"fmt"
"os"
)
var version = "dev"
func main() {
if len(os.Args) > 1 && os.Args[1] == "--version" {
fmt.Println(version)
return
}
fmt.Println("hello-go")
}// src/hello-go/go.mod
module example.com/hello-go
go 1.22
Theory 3 — Rust path (buildRustPackage)
{ lib, rustPlatform, fetchFromGitHub }:
rustPlatform.buildRustPackage rec {
pname = "hello-rs";
version = "0.1.0";
src = fetchFromGitHub {
owner = "you";
repo = "hello-rs";
rev = "v${version}";
hash = "sha256-…";
};
cargoHash = "sha256-…";
meta = {
description = "Tiny Rust greeter";
license = lib.licenses.mit;
mainProgram = "hello-rs";
};
}Alternatively lock from a committed Cargo.lock:
cargoLock = {
lockFile = ./Cargo.lock;
# outputHashes = { "foo-0.1.0" = "sha256-…"; }; # for git deps
};Follow current nixpkgs examples for 26.05 if cargoHash vs git deps bit you.
Git dependencies in Cargo
Vendoring git crates often needs cargoLock.outputHashes for each git source. Expect deep debugging the first time—same skills as the debug builds chapter.
Useful knobs
| Attr | Purpose |
|---|---|
cargoBuildFlags |
Extra flags |
cargoTestFlags |
Test control |
buildAndTestSubdir |
Workspace member |
doCheck |
Run tests |
auditable |
Supply-chain related (when available) |
Theory 4 — Python path (application vs library)
{ lib, python3Packages, fetchPypi }:
python3Packages.buildPythonApplication rec {
pname = "hello-py";
version = "0.1.0";
pyproject = true;
src = fetchPypi {
inherit pname version;
hash = "sha256-…";
};
build-system = with python3Packages; [ setuptools ];
dependencies = with python3Packages; [ click ];
meta = {
description = "Tiny Click CLI";
license = lib.licenses.mit;
mainProgram = "hello-py";
};
}Local app:
{ lib, python3Packages }:
python3Packages.buildPythonApplication {
pname = "hello-py";
version = "0.1.0";
pyproject = true;
src = ../src/hello-py;
build-system = with python3Packages; [ setuptools ];
meta = {
mainProgram = "hello-py";
license = lib.licenses.mit;
};
}| Builder | When |
|---|---|
buildPythonApplication |
CLI / end-user tool on PATH |
buildPythonPackage |
Library for others to depend on |
Prefer existing python3Packages.* deps over vendoring wheels by hand. Align interpreters (python311 vs python312) across the graph.
pytest pattern (sketch)
nativeCheckInputs = with python3Packages; [ pytestCheckHook ];
# or pytest in checkPhase via hooks the builder providesTheory 5 — Node (buildNpmPackage) sketch
{ lib, buildNpmPackage, fetchFromGitHub }:
buildNpmPackage rec {
pname = "hello-node";
version = "0.1.0";
src = fetchFromGitHub {
owner = "you";
repo = "hello-node";
rev = "v${version}";
hash = "sha256-…";
};
npmDepsHash = "sha256-…";
meta = {
mainProgram = "hello-node";
license = lib.licenses.mit;
};
}package-lock.json (or supported lock) must exist; hash tracks the npm dependency FOD. Lock format churn changes hashes—commit the lockfile.
Theory 6 — Version injection and meta.mainProgram
| Ecosystem | Version injection idea |
|---|---|
| Go | ldflags = [ "-X main.version=${version}" ]; |
| Rust | env.VERGEN_* or clap long_version via build.rs / env |
| Python | single-source version in package metadata |
| Node | package.json version; avoid drift with Nix version |
Always set:
meta.mainProgram = "hello-go";so nix run finds the binary without guessing.
Theory 7 — Tests and purity
| Situation | Approach |
|---|---|
| Unit tests offline | Keep doCheck = true |
| Tests need network | Patch, mock, or disable with a comment |
| Tests need golden files with timestamps | Fix tests, don’t disable sandbox |
| Integration tests need services | Prefer nixosTest later; not every unit suite |
Disabling checks forever without a ticket is how bitrot lands in production images.
Worked example — flake with Go tool + check
{
description = "language ecosystem A lab";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
packages = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; }; in {
default = pkgs.callPackage ./pkgs/hello-go.nix { };
hello-go = pkgs.callPackage ./pkgs/hello-go.nix { };
});
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/hello-go";
};
});
checks = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; }; in {
hello-go-version = pkgs.runCommand "hello-go-version" {
nativeBuildInputs = [ self.packages.${system}.default ];
} ''
hello-go --version | tee "$out"
'';
});
devShells = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; }; in {
default = pkgs.mkShell {
packages = [
pkgs.go
pkgs.gopls
self.packages.${system}.default
];
};
});
};
}Adapt builders and binary names if your primary language is not Go.
Lab — multi-step
Suggested workspace: ~/lab/nixos/packaging/eco-a
Step 1 — Choose primary language
Write the choice in NOTES.md: Go / Rust / Python / Node / other. Stick to it for all steps.
Step 2 — Minimal upstream project
Either:
- Create a tiny local project under
src/, or
- Fetch a small tagged tool you actually use.
Requirements: builds offline once vendored; one CLI entrypoint; license known.
Step 3 — First package expression
Use the matching builder. Start with lib.fakeHash / fake vendor hashes as appropriate.
nix build .#default -LIterate until green.
Step 4 — Wire meta.mainProgram and run
nix run .#
# or
./result/bin/<name> --helpStep 5 — Version injection
Pass version via ldflags (Go), env/build.rs (Rust), or package metadata (Python/Node). Prove tool --version matches derivation version where feasible.
Step 6 — Tests
Enable package checks if feasible. If tests need network, patch or disable with a comment explaining why—not silence.
Step 7 — Consume from a devShell
nix develop
which <tool>Optional: add to a NixOS environment.systemPackages via flake package output.
Step 8 — Break the vendor/cargo/npm hash
Edit a dependency version or lock line; rebuild; observe hash mismatch; restore or update intentionally.
Step 9 — Closure glance
nix path-info -rS ./result | sort -k2 -n | tail
nix path-info -Sh ./resultNote interpreter vs pure binary closure differences.
Exercises
- Map your builder’s phases back to stdenv names in a table.
- Produce a second output binary with
subPackagesor workspace members (if applicable).
- Add a
passthru.testsor flakechecksthat fails if--versiondrifts.
- Cross-compile awareness: read whether your builder sets
GOOS/CARGO_BUILD_TARGET—document only.
- Find the same tool in nixpkgs (
nix edit nixpkgs#…) and list three techniques they use that you skipped.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
vendorHash / cargoHash / npmDepsHash mismatch |
Update after intentional lock change; never ignore silently |
| Go build tries network in buildPhase | Modules must be vendored via FOD; check proxyVendor / vendor dir |
| Rust git dependency hash missing | Add outputHashes for the git crate |
| Python module import works in tests but CLI missing | Use buildPythonApplication + meta.mainProgram |
| Node package missing lockfile | Generate supported lock; don’t hand-wave node_modules |
mainProgram wrong |
Fix meta; nix run depends on it for default bin |
| CGO / shared libs surprise | Set CGO_ENABLED, add buildInputs for libs |
| Building entire monorepo | subPackages / workspace members / flags |
| Mixed python3 versions | Align package sets |
| Disabled checks permanently | Ticket + comment or fix |
Checkpoint
- Primary language chosen and recorded
- Package builds with official language helper
- Vendor/cargo/npm hash workflow demonstrated
nix runor./result/binworks
- Version string controlled from Nix where possible
- Notes on how this maps back to stdenv phases
- At least one check or test story
- Closure size noted
Commit
git add .
git commit -m "packaging: language ecosystem A package"Write three personal gotchas before part B.