Language ecosystems (part A)

Updated

July 30, 2026

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.

Tip

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

Shared skeleton

src FOD → language-dep FOD → build/install hooks → $out/bin → meta

When stuck, ask first: which hash is wrong—source or language deps?


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

  1. Set vendorHash = lib.fakeHash; (or null only if truly module-free).
  2. Build; read got: hash.
  3. Paste; rebuild.
  4. When go.mod / go.sum change, vendorHash changes.
nix build .#hello-go -L

Useful 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 provides

Theory 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 -L

Iterate until green.

Step 4 — Wire meta.mainProgram and run

nix run .#
# or
./result/bin/<name> --help

Step 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 ./result

Note interpreter vs pure binary closure differences.


Exercises

  1. Map your builder’s phases back to stdenv names in a table.
  2. Produce a second output binary with subPackages or workspace members (if applicable).
  3. Add a passthru.tests or flake checks that fails if --version drifts.
  4. Cross-compile awareness: read whether your builder sets GOOS/CARGO_BUILD_TARGET—document only.
  5. 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 run or ./result/bin works
  • 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.