8000 pkgs/AGENTS.md at main · gominimal/pkgs · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
905 lines (646 loc) · 40.2 KB

AGENTS.md

File metadata and controls

905 lines (646 loc) · 40.2 KB

AGENTS.md

This file provides guidance for agents working with code in this repository.

pkgs repository

You are in the packaging repository for the Minimal build system & package manager, containing the config-as-code declarations for an ecosystem of Linux software packages (like nixpkgs). These descriptions are fairly precise, describing both the dependencies of the software (in terms of other packages) as well as the exact steps to build it and what outputs are collected from a build.

You are running in an environment with access to the in-sandbox min helper. Through it you can search for and install packages, record a package as a dependency, run tasks declared in minimal.toml, lint this repo's declarations, and build a package.

Do not run a min command from memory, and do not copy command spellings out of this file. The helper is installed by the Minimal daemon, so its subcommands track the daemon's version and have changed between releases: verbs have been added, renamed, and removed. Resolve them at the point of use, from these two sources:

Both win over anything written here or remembered from another machine. If you have no shell in your context and cannot check, say which command you would resolve rather than emitting a plausible-looking one that may not exist.

Two behaviours to know before you start, because neither is guessable:

  • Installing a package and recording it as a dependency are different actions. A bare install need not persist anything, and what it records differs between a session and a task sandbox. Check the reference for the flag that writes the package into build_deps / runtime_deps; a package you installed ad hoc works for you and fails for everyone else.
  • Tasks marked interactive = true (such as shell and claude) can only be launched from the host. Running one from inside a session fails with "cannot run interactive tasks from within an environment". That is a structural limit, not a misconfiguration; do not work around it.

General layout

All objects are described using Nickel syntax, and objects are roughly located at the path <object-type>/<name>/<file>.ncl.

Specifically:

  • Packages: packages/<package name>/build.ncl
  • Stacks: stacks/<stack name>/stack.ncl

The repo-level config lives at minimal.toml (declares the minimum stdlib version and interactive tasks such as shell and claude).

Stacks

A stack describes a reusable build environment for a class of project (e.g. a Go module, a Rust crate, a CMake project). Each stack declares the packages it needs, a default build command, and a set of project-detection rules — minimal init uses these rules to auto-select the right stack for a source tree.

Example (stacks/go/stack.ncl):

let { stack, .. } = import "minimal.ncl" in
stack {
  name = "go",
  build_packages = ["go", "binutils", "linux_headers"],
  build_cmd = "go build",
  matches_project_if_any = [
    { file_regexes = { "go.mod" = "*", "go.sum" = "*" } },
  ],
}

Stacks were previously called "harnesses". The stdlib still exports harness as an alias for stack, but new stacks should use stack and live at stacks/<name>/stack.ncl.

Current stacks cover: aeneas, bun, cabal, cmake, deno, go, gradle, make, maven, meson, npm, ocaml, odin, pip, pnpm, pulumi-go, pulumi-nodejs, rust, shell, stack, uv, zig.

Tests

tests/<name>/ holds checks that exercise packages the way users get them, across several packages at once. A single package's checks stay in that package's tests block. Each directory under tests/:

  • is a self-contained min project: its own minimal.toml, with [upstream] pinning gominimal/pkgs (locked_commit) and [tasks] to run. It is not a package or a stack, nothing imports it, and the package checks never evaluate it;
  • tests the commit its pin names, never the checkout it sits in. min update re-pins it to main, or [upstream] can point at a PR head;
  • has a README saying how to run it, what each result means, and who acts on a failure;
  • holds no secrets or private keys. Captured data (certificates, fixtures) is public, pinned by checksum, and cites its source;
  • has no profiles/ subdirectory, because min reserves profiles/<name>/profile.ncl.

Current tests: tls-trust (every TLS client refuses a distrusted CA, per distrust response).

Package declarations

A package is the fundamental unit of software. Packages are reproducible, maintainable, and dependency-aware building blocks designed for environment parity across the SDLC.

Structurally, a package is the union of its declarative build specification and the resulting binary artifacts. This relationship is cemented by a layer of metadata that defines the package's dependencies, architecture, and provenance. By binding the "recipe" to the "result," Minimal ensures that every component of your environment is transparent, verifiable, and maintainable at scale.

Overview

A package is defined using Nickel syntax at the path packages/<name>/build.ncl. This nickel evaluates to a BuildSpec structure that describes the package, as well as its dependencies, how to build it, and a bunch of metadata.

A package build.ncl looks roughly like this:

let { BuildSpec, Local, Source, OutputBin, .. } = import "minimal.ncl" in
let bash = import "../bash/build.ncl" in
let go = import "../go/build.ncl" in
let glibc = import "../glibc/build.ncl" in

let version = "2.1.0" in
{
  name = "my-go-tool",

  build_deps = [
    { file = "build.sh" } | Local,
    {
      url = "https://github.com/owner/repo/archive/refs/tags/v%{version}.tar.gz",
      sha256 = "abc123...",
      extract = true,
      strip_prefix = "repo-%{version}",
    } | Source,
    bash,
    go,
  ],
  runtime_deps = [glibc],

  cmd = "./build.sh",
  build_args = { include version },

  outputs = {
    bins = { glob = "usr/bin/*" } | OutputBin,
  },

  attrs = {
    upstream_version = version,
    source_provenance = {
      category = 'GithubRepo,
      owner = "owner",
      repo = "repo",
    },
  },
} | BuildSpec

Notice the general structure:

  • Imports at the top
  • Followed by variables, which can be used for string substitution or included / set elsewhere
  • The BuildSpec structure, including the package name, the build dependencies, runtime dependencies, the Source structure(s) (which indicates where to get the source code, it will be made present in the cwd for the build), and any Local structures, which also bring across adjacent files into the build cwd.

Imports

A Minimal package usually starts with a bunch of imports:

  • Nickel types that need to be applied, such as BuildSpec, Source, Local etc, these are imported from the magic 'minimal standard library' path minimal.ncl.
  • Other packages needed for dependencies. These are simple lines that import the build.ncl file that describes that package and assigns them to a variable. These can then be used as an element of the build_deps array (if the package is needed during the build), or as an element of the runtime_deps array, or to make a subset or as a test dependency.

Example import line for nickel types:

let { subsetOf, BuildSpec, Local, Source, OutputBin, Test, .. } = import "minimal.ncl" in

The import line for types from minimal.ncl should:

  1. Be the first line in the file, and
  2. Identifiers should be ordered with lowercase identifiers first (in alphabetical order), before uppercase identifiers (also in alphabetical order).

Example import line for packages needed as dependencies:

let rust = import "../rust/build.ncl" in
let gcc = import "../gcc/build.ncl" in
let bash = import "../bash/build.ncl" in

The identifier for an imported package should match the package name being imported. In nickel, identifiers are allowed to contain hyphens, so let bash-bootstrap = import "../bash-bootstrap/build.ncl" in is totally valid.

Dependencies

There are two places to declare a packages' dependencies: The build_deps array, and the runtime_deps array.

A package belongs as a build_deps entry if it is needed during the build but not whenever the package itself is used. An example of this might be tar if a source tarball needs to be extracted by the build script, or go for building Golang source code into a binary.

A package belongs as a runtime_deps entry if it is needed anywhere the package itself would be deployed. Examples of this include glibc for built binaries that link with glibc, similarly for openssl. Interpreted languages like packages that are python scripts typically need their intepreter as a runtime dep as well. All entries in runtime_deps will be injected into the build environment, so there's no need to have an identical entry in build_deps if you have a dependency in runtime_deps.

Any package listed in build_deps that itself has runtime_deps (transitive runtime_deps) will also be injected into the build environment.

There are two common packages worth noting: base and toolchain.

  • base - Contains bash,glibc,coreutils,grep,awk etc. All the standard basic linux CLI stuff you expect.
  • toolchain - Contains gcc/g++/binutils etc. The 'basic' C toolchain.

These should be used where possible in lieu of exhaustively listing dependencies.

Subsets: depending on some libraries in a package

Some packages are really large, like gcc, but provide important libraries that are used a lot like libgcc or libstdc++. To avoid any user of a package needing to download a whole gcc, its possible to depend on only some outputs of a package. For instance, the gcc package represents libgcc and libstdc++ with outputs libgcc and libstdcpp, so if a program needed both libraries, you could use:

subsetOf gcc ["libgcc", "libstdcpp"],

as an entry to runtime_deps. Don't forget to add subsetOf to the import line for minimal.ncl.

Build steps/script

The program to run to complete the build is declared using the cmd field, and by convention its always an adjacent shell script ./build.sh. Theres also a build_args field to pass values across from the config into this invocation as environment variables: each key/value entry shows up as an env var MINIMAL_ARG_<KEY> where key is uppercase.

The usual pattern is to bind version at the top of build.ncl and forward it through build_args so build.sh can refer to it as $MINIMAL_ARG_VERSION rather than hardcoding the value. This keeps version bumps to a single edit in build.ncl:

let version = "5.3" in
{
  # ...
  build_args = { include version },
}
cd bash-$MINIMAL_ARG_VERSION

See packages/bash/build.ncl and packages/bash/build.sh for a complete example.

All files to be captured from the build must be stored in $OUTPUT_DIR, i.e. make DESTDIR=$OUTPUT_DIR install.

In addition to creating the executable ./build.sh script, you need to declare it as a build dependency, so the system knows the file needs to be placed into the cwd of the build. This can be done by declaring the file as a Local dependency in the build_deps array:

{ file = "build.sh" } | Local

Source code

The build of some software works on a copy of the source code. This is done by adding a Source dependency to the build_deps array:

{
    url = "https://someproject.org/downloads/source-tarball.tar.gz",
    sha256 = "abcdef123...",
} | Source,

When a source is present in a build_deps array, the system will fetch the file and place it in the working directory of the build. You can also have the system unpack the tarball into the working directory for you:

{
    url = "https://someproject.org/downloads/thingy-v1-source-tarball.tar.gz",
    sha256 = "abcdef123...",
    extract = true,
    strip_prefix = "thingy-v1",
} | Source,

Note that unpacking is supported for: .tar.gz, .tgz, .tar.xz, .tar.zst, .tar.bz2, .tar.

If you encounter any errors in the automatic unpacking of the tarball, turn off extraction, add tar to build_deps, and extract manually in build.sh:

build_deps = [
  { file = "build.sh" } | Local,
  { url = "...", sha256 = "..." } | Source,
  tar,
  ...
],
tar -xf source-tarball-$MINIMAL_ARG_VERSION.tar.gz
cd source-dir-$MINIMAL_ARG_VERSION

(Forwarding version via build_args = { include version } — see "Build steps/script" above — lets build.sh stay untouched across version bumps.)

Outputs

The files captured from a build and represented by a package are explicitly declared in an outputs field, which is a map of output names to a structure describing a file glob to capture. There are three categories of output, any of which must be imported if used: OutputBin (for binaries), OutputLib (for shared libraries), and OutputData (for data files / internals).

Here's an example of setting the outputs field to capture a single output named bins which is just all the binaries emitted:

outputs = {
  bins = { glob = "usr/bin/*" } | OutputBin,
},

Attributes

Metadata about the package can be captured in an attrs field. This is typed - it is not a free-for-all.

Use upstream_version for the version of the software being packaged

You can set the upstream_version entry to be the version of the software being packaged, if known.

eg, assuming a variable defined earlier with version in it:

attrs = {
upstream_version = version,
},

Use source_provenance to document software from github or if its a GNU project

Do NOT set this to point to a mirror or any unofficial copy/website. Prefer to omit this field if you are not certain of the canonical origin of source code for this project.

If it came from github:

source_provenance = {
  category = 'GithubRepo,
  owner = "<github account>",
  repo = "<github repo>",
},

If it came from a GitLab instance (host names the instance, e.g. gitlab.com; see packages/psmisc, packages/tree):

source_provenance = {
  category = 'Gitlab,
  host = "gitlab.com",
  owner = "<gitlab group or user>",
  repo = "<gitlab project>",
},

If its a gnu project:

source_provenance = {
  category = 'GnuProject,
  name = "<project name, eg gzip>",
},

Omit it if its not one of these variants.

One exception: official upstream mirrors. Some upstreams develop on a self-hosted forge but publish an official GitHub mirror themselves. GNOME, for example, develops on gitlab.gnome.org and mirrors to github.com/GNOME. Such a package may declare 'GithubRepo pointing at the mirror when all of the following hold:

  • the mirror lives in a GitHub org or account that the upstream project itself controls (a mirror that the upstream's docs merely link to, run by someone else, does not qualify);
  • the mirror carries the same release tags at the same commit SHAs as the canonical forge (spot-check the tag you package);
  • the package needs it for advisory coverage. Today the scanner's GitHub path keeps a package's curated CPE identity, but its GitLab path consults the curated identity only when the GitLab identity holds no advisories at all, so a self-hosted GitLab provenance can drop NVD-CPE coverage.

This covers source_provenance only. Where the build fetches its Source from is a separate choice: libxml2 and libxslt fetch the mirror's tag archives, glib a staging copy of GNOME's release tarball.

Say so in a comment beside the provenance block. The packages using this exception are glib, libxml2 and libxslt; adding a fourth means adding it to this list in the same PR. The exception ends when minimal-supply-chain's GitLab path also consults the curated CPE identity (gominimal/pkgmgr-rs#1029): then move all three to 'Gitlab with host = "gitlab.gnome.org".

Other fields

needs

Declares sandbox capabilities the build requires. Most packages don't need this, but builds that fetch dependencies over the network (Go modules, cargo crates, npm packages) must opt in:

needs = {
  dns = {},
  internet = {},
},

prebuilt

Declares the package's output as a checked-in prebuilt binary rather than something built from source during the pipeline. Typically used for toolchain-bootstrap packages that need a working binary before the toolchain itself can compile anything — see packages/bash-bootstrap/build.ncl as an example.

The source tarball for a prebuilt = true package must already match the on-disk layout that the package emits — there's no build step to move files around. For example, a prebuilt bash package needs its tarball to contain the binary at usr/bin/bash so it lands where the OutputBin glob expects it.

Trust store

The system trust store is the ca-certificates package. It installs the PEM bundle /etc/ssl/certs/ca-certificates.crt, one <Label>.crt file per root, and the OpenSSL <subject_hash>.<n> links next to them. openssl is built with --openssldir=/etc/ssl, so OpenSSL and what links it (curl, git, Python's ssl, Ruby) look for roots there. Go's crypto/x509 and rustls-native-certs (through openssl-probe) do not go through openssldir: they probe /etc/ssl/certs themselves, which this layout also satisfies.

How the store reaches an environment

Every environment mounts exactly a dependency closure and nothing else. A build sandbox gets the package's build and runtime deps with their runtime closures. A standalone test, a session or task, and an OCI image get the runtime closure of their packages. ca-certificates gets into a closure in one of two ways:

  1. Injected. ca-certificates sets attrs.needed_for_internet. minimal adds every package with that attribute to the closure of any package that declares needs.internet. The injection then follows that package into the closure of everything that depends on it at runtime (minimal crates/graph/src/transitives.rs, Transitives::new_with_seenset). curl and wget declare needs.internet, so anything that brings in curl, git for example, also gets the store. The injected edge is not part of any spec hash, so a new bundle rebuilds none of these packages.
  2. Declared. ca-certificates is listed in runtime_deps. This edge is hashed, so every bundle update rebuilds the package and everything that depends on it.

The planner also adds needed_for_internet packages to the top level of every build plan (the HACK in ExecPlan::with_toplevels, crates/graph/src/planner.rs). That only gets ca-certificates built. It does not put it into any environment. A session with packages = ["python"] has no /etc/ssl/certs.

Rules

  • Declare the store when you read it. If a package's binaries verify TLS against /etc/ssl/certs at runtime and nothing in its runtime closure already brings in ca-certificates, add ca-certificates to its runtime_deps. ruby, typst, atuin, gws and skopeo do this.

  • Not in the toolchain closure. python, perl and openssl are build inputs of nearly every package. A ca-certificates edge on any one of them rehashes about 90% of the tree now, and again on every bundle update. openssl cannot take the edge at all, because ca-certificates build-depends on it. These three get the store only when something else in the environment brings it in. If you ship a Python or Perl program that talks TLS, add ca-certificates to that package.

  • Do not use needs.internet to get the store. It also gives network access to the package's own build and to the build of everything that has it in its closure.

  • Do not embed a trust store. A runtime reads /etc/ssl/certs through a build flag, a config default or a wrapper. It does not compile in its own roots or vendor a certifi-style bundle. Several upstreams default to a store of their own: node's bundled roots, bun, deno's webpki-roots, the JDK cacerts keystore, Chromium's root store, gcloud's vendored bundles, and Rust crates that pin webpki-roots. Switch them to the system store where upstream allows it. Where it does not, the package applies the denylist below, and its build.ncl says why it cannot use the system store.

  • Denylists: two today, meant to be one. Distrusted roots, intermediates and keys are listed as SHA-256 fingerprints in two files under packages/ca-certificates/, and each distrust response has to land in both until they converge (gominimal/pkgmgr-rs#1020):

    • distrust.txt (<cert|spki> <sha256> <note> lines, one block per distrust response citing its CVE and the Mozilla/NSS bugs). Read by the ca-certificates build gate (the build fails if any certificate it ships matches), by the Chromium policy checker (.github/scripts/chromium_policy_distrust_check.py, which requires every key in packages/chromium-policies/ca-distrusted-certificates.json to be an spki line here) and by pkgmgr truststores.
    • cve-2026-111943-blocked-sha256.txt, exposed through cve-2026-111943.ncl. Read by the JDK patch (packages/jdk/cve-2026-111943.sh, used by jdk-21, jdk-25 and jdk-27), which needs all 48 certificate fingerprints, every malleable encoding of the compromised intermediates, because blocked.certs matches whole certificates, not keys.

    Append a block for each distrust response and never delete one. Patched embedded stores read one of these files rather than keeping their own copy.

  • When a CA is distrusted: rebuild ca-certificates from a dated curl/Mozilla extract, append the response's block to distrust.txt (and, while the JDK patch still reads its own file, give it the response's full certificate fingerprints), then fix every embedded store listed above. A package that only reads /etc/ssl/certs needs no edit; it gets the new bundle through its closure.

Creating packages workflow

Step-by-step guide to creating a new package for the Minimal registry.

When to create a package: If a task needs a tool or library that doesn't exist in the registry, the correct approach is to create it — never install it on the host system.

Step 0: Confirm it's missing

Search the registry for the name (the helper's search command; run bare min for its current spelling). It prints similarly-named packages.

Check alternate names before concluding a package is absent: python not python3, node not nodejs, jdk not java.

Step 1: Create the package directory

mkdir packages/<name>

Step 2: Write build.ncl

At minimum, a build spec needs:

  • name matching the directory name
  • build_deps with a Local entry for build.sh and a Source entry for the upstream archive
  • cmd = "./build.sh"
  • outputs declaring what the package produces

Finding the source URL and SHA256

  1. Find the upstream release (GitHub releases, project website)
  2. Get the archive URL (prefer archive/refs/tags/ for GitHub)
  3. Compute the hash:
    curl -sL <url> | sha256sum

Verify the canonical upstream repo before using it. GitHub projects get renamed, transferred, or forked — a repo you find via search or an older link may be stale and silently redirect. Before locking in a URL:

  • Resolve the repo with curl -sIL https://github.com/<owner>/<repo> | grep -i '^location:' (or open it in a browser) and check whether it redirects. If it does, use the new canonical owner/repo in both the url and source_provenance.
  • Cross-check the project's own README, homepage, or package registry page (npm, crates.io, PyPI) to confirm you have the current canonical source.

Getting this wrong means future version bumps chase a dead repo and source_provenance lies about where the code came from.

Build from source — do not package prebuilt release binaries. When the upstream project's toolchain is available in pkgs (bun, go, rust, cargo, node, etc.), the package must build from the source tarball rather than downloading a prebuilt binary from the release page. Reasons:

  • Prebuilt artifacts can be mutated after the fact; source builds give us a real supply-chain guarantee.
  • Source builds let us tweak flags for reproducibility and patch issues without waiting on upstream.
  • The toolchain is already there — there's no meaningful cost saving from shipping the prebuilt.

Only fall back to a prebuilt binary if the required toolchain genuinely isn't packaged yet, and call that out explicitly in the package.

JVM tools are such a case for now. maven and gradle are themselves upstream binary distributions, and nothing stages Maven Central dependencies for an offline build the way cargo vendor, goproxy and pnpm fetch do. Until both exist (#969), a tool that is JVM bytecode only may ship its upstream release jar if:

  • its sha256 matches the one upstream publishes;
  • binary_from records it;
  • it runs on the packaged jdk and does not download a JDK of its own;
  • the tool's own code is complete in that checksummed artifact: every jar it loads to run is bundled (or shaded in), so it starts and runs offline, and it never downloads or executes code of its own outside that sha256.

Fetching on the user's behalf is the tool's job, not part of its own code: Maven and mvnd resolving a project's dependencies and plugins, jbang fetching a script's deps, or a search tool querying Maven Central for metadata. Declare that through needs, the same way cargo, uv and curl do. What the rule excludes is a launcher that bootstraps its own implementation from the network (a wrapper that downloads the real tool, a plugin manager that fetches the tool's own components on first run).

This exception does not cover native code shipped alongside the jars (for example a GraalVM native image or bundled .so files); justify that separately. When #969 lands, these packages convert to source builds together.

Example: C library (autotools)

let { BuildSpec, Local, Source, OutputBin, OutputLib, OutputData, Test, .. } = import "minimal.ncl" in

let version = "1.4.1" in

let gcc = import "../gcc/build.ncl" in
let make = import "../make/build.ncl" in
let bash = import "../bash/build.ncl" in

{
  name = "my-library",

  build_deps = [
    { file = "build.sh" } | Local,
    {
      url = "https://github.com/owner/repo/archive/refs/tags/v%{version}.tar.gz",
      sha256 = "abc123def456...",
      extract = true,
      strip_prefix = "repo-%{version}",
    } | Source,
    bash,
    gcc,
    make,
  ],

  runtime_deps = [],

  cmd = "./build.sh",
  build_args = {
    include version,
  },

  outputs = {
    bins = { glob = "usr/bin/*" } | OutputBin,
    libs = { glob = "usr/lib/*.so*" } | OutputLib,
    headers = { glob = "usr/include/**" } | OutputData,
  },

  attrs = {
    upstream_version = version,
    source_provenance = {
      category = 'GithubRepo,
      owner = "owner",
      repo = "repo",
    },
  },

  tests = {
    version_check = {
      class = 'Standalone,
      test_deps = [],
      cmds = [["my-tool", "--version"]],
    } | Test,
  },
} | BuildSpec

Example: Go tool

let { BuildSpec, Local, Source, OutputBin, Test, .. } = import "minimal.ncl" in

let version = "2.1.0" in

let go = import "../go/build.ncl" in
let bash = import "../bash/build.ncl" in

{
  name = "my-go-tool",

  build_deps = [
    { file = "build.sh" } | Local,
    {
      url = "https://github.com/owner/repo/archive/refs/tags/v%{version}.tar.gz",
      sha256 = "abc123...",
      extract = true,
      strip_prefix = "repo-%{version}",
    } | Source,
    bash,
    go,
  ],

  cmd = "./build.sh",
  build_args = { include version },

  outputs = {
    bins = { glob = "usr/bin/*" } | OutputBin,
  },

  needs = {
    dns = {},
    internet = {},
  },

  attrs = {
    upstream_version = version,
    source_provenance = {
      category = 'GithubRepo,
      owner = "owner",
      repo = "repo",
    },
  },
} | BuildSpec

Example: Rust tool

let { BuildSpec, Local, Source, OutputBin, Test, .. } = import "minimal.ncl" in

let version = "0.5.0" in

let rust = import "../rust/build.ncl" in
let gcc = import "../gcc/build.ncl" in
let bash = import "../bash/build.ncl" in

{
  name = "my-rust-tool",

  build_deps = [
    { file = "build.sh" } | Local,
    {
      url = "https://github.com/owner/repo/archive/refs/tags/v%{version}.tar.gz",
      sha256 = "abc123...",
      extract = true,
      strip_prefix = "repo-%{version}",
    } | Source,
    bash,
    gcc,
    rust,
  ],

  cmd = "./build.sh",
  build_args = { include version },

  outputs = {
    bins = { glob = "usr/bin/*" } | OutputBin,
  },

  attrs = {
    upstream_version = version,
    source_provenance = {
      category = 'GithubRepo,
      owner = "owner",
      repo = "repo",
    },
  },
} | BuildSpec

Step 3: Write build.sh

The build script must install everything to $OUTPUT_DIR.

Every package must be reproducible — layer the determinism flags from Reproducibility (required) below (which routes to https://minimal.dev/docs/reference/reproducibility) onto whichever pattern you use. The patterns below show the minimal shape; they are not complete without those flags.

Autotools pattern

#!/bin/bash
set -euo pipefail

cd my-library-$MINIMAL_ARG_VERSION

export CFLAGS="-march=x86-64-v3 -O3 -pipe"
export CXXFLAGS="$CFLAGS"

./configure --prefix=/usr
make -j$(nproc)
make DESTDIR=$OUTPUT_DIR install

CMake pattern

#!/bin/bash
set -euo pipefail

cd repo-$MINIMAL_ARG_VERSION

cmake -B build -G Ninja \
  -DCMAKE_INSTALL_PREFIX=/usr \
  -DCMAKE_BUILD_TYPE=Release
ninja -C build
DESTDIR=$OUTPUT_DIR ninja -C build install

Go pattern

#!/bin/bash
set -euo pipefail

cd repo-$MINIMAL_ARG_VERSION

export GONOSUMCHECK=*
export GONOSUMDB=*

go build -o $OUTPUT_DIR/usr/bin/my-tool .

Rust pattern

#!/bin/bash
set -euo pipefail

cd repo-$MINIMAL_ARG_VERSION

export CC=gcc
export LD=gcc
export RUSTFLAGS="-C linker=gcc"

cargo build --release

mkdir -p $OUTPUT_DIR/usr/bin
cp target/release/my-tool $OUTPUT_DIR/usr/bin/

Node pattern

Node CLIs follow three rules. .github/workflows/node-runtime-guard.yml enforces all of them, because breaking them blends two npm installs into one that cannot load (#665, #751):

  • Use node-lts, not node, for build_deps and runtime_deps; the guard checks both. With one flavor, every package ships the same /usr/bin/node. node (Current) is there for users to opt into; packages don't use it. Use node-lts for test_deps too. That part is convention only: minimal dump doesn't include tests, so the guard can't see them.
  • At runtime, depend on the interpreter only: subsetOf node-lts ["node"]. Never take the whole package, which would bring its npm, npx and usr/lib/node_modules into the user's session.
  • Install into usr/libexec/<pkg>, never usr/lib/node_modules. That tree belongs to the runtime's own npm. Expose each bin as a relative symlink, and name the private tree in outputs. A package that builds with Node may not use a broad glob like usr/** that would also capture usr/lib/node_modules.
#!/bin/sh
set -eu

npm install -g --prefix="$OUTPUT_DIR/usr/libexec/my-tool" "my-tool@$MINIMAL_ARG_VERSION"

mkdir -p "$OUTPUT_DIR/usr/bin"
for _tool in my-tool; do  # the bins package.json declares
  ln -s "../libexec/my-tool/bin/$_tool" "$OUTPUT_DIR/usr/bin/$_tool"
  test -e "$OUTPUT_DIR/usr/bin/$_tool"  # fail on a renamed upstream bin
done
  build_deps = [{ file = "build.sh" } | Local, base, node-lts],
  runtime_deps = [coreutils, subsetOf node-lts ["node"]],  # coreutils: `#!/usr/bin/env node`
  outputs = {
    my-tool = { glob = "usr/bin/my-tool" } | OutputBin,
    libexec = { glob = "usr/libexec/my-tool/**", allow_executable = true } | OutputData,
  },

pkgmgr import npm <name> generates this layout (using a committed lockfile and npm ci); see packages/vlt.

Ruby pattern

ruby, ruby-3.3 and ruby-3.4 install the same paths (usr/bin/ruby, usr/lib/ruby/**, libruby.so, usr/etc/gemrc), so two of them in one closure blend into one broken interpreter. .github/workflows/ruby-flavor-guard.yml enforces two rules:

  • Use ruby, not a versioned line, in build_deps and runtime_deps. ruby-3.3 and ruby-3.4 are there for users to opt into, like node (Current); packages don't use them. An exception goes in VERSIONED_ALLOWED in .github/scripts/ruby_flavor_guard.py, with the reason.
  • No closure may hold two Ruby lines, runtime or build, and that includes stacks: a stack naming ruby-3.4 next to ruby-lsp fails. A user's own loadout isn't in the catalog, so the guard can't see ruby-3.4 + ruby-lsp chosen there.

OpenSSL pattern

openssl is the 4.x line: libssl.so.4 / libcrypto.so.4 in /usr/lib, providers in /usr/lib/ossl-modules, no engines. openssl-3.5 is a compat package on the 3.5 LTS line for code that cannot build or run against 4.x. Its runtime libraries are /usr/lib/libssl.so.3 and /usr/lib/libcrypto.so.3, and everything else (headers, .so dev links, pkg-config, CMake files) sits under /usr/lib/openssl-3.5, so its paths are disjoint from openssl's and both can be in one closure. Their symbols are versioned (OPENSSL_4.* vs OPENSSL_3.*), so a process that loads both binds each reference to its own library.

  • Port to 4.x first. Most breakage is a removed API with a small upstream fix: python carries cpython#149783, socat a Debian ASN1 patch, wget drops SSLv3_client_method, and Rust crates move to openssl-sys 0.9.117+. Use openssl-3.5 only when that isn't possible: a sealed or prebuilt binary that NEEDs libssl.so.3 (the bedrock rustc's cargo, actions-runner's bundled .NET 8), or a codebase that doesn't build on 4.x yet (foundationdb 7.3).
  • To build against it: add openssl-3-5 (import "../openssl-3.5/build.ncl") to build_deps and point the build at the prefix: PKG_CONFIG_PATH=/usr/lib/openssl-3.5/lib/pkgconfig, OPENSSL_ROOT_DIR=/usr/lib/openssl-3.5 (CMake) or OPENSSL_DIR=/usr/lib/openssl-3.5 (openssl-sys).
  • At runtime, depend on the libraries only: subsetOf openssl-3-5 ["libssl", "libcrypto"].
  • Check the result in build.sh, fail-closed: readelf -d should show the binary NEEDs libssl.so.3 and never lib(ssl|crypto).so.4 (see packages/foundationdb/build.sh).
  • 4.1 drops the CN fallback. Hostname verification no longer falls back to the subject CN when a certificate has no SAN of the checked type (openssl/openssl#31982). A test certificate needs a subjectAltName (-addext subjectAltName=DNS:localhost,IP:127.0.0.1).

Reproducibility (required)

Every package in this repo must build byte-reproducibly. The per-toolchain determinism flags — C/C++, Go, Rust, the Linux kernel, and builds that stamp their own wall-clock time — are documented once, publicly, at:

https://minimal.dev/docs/reference/reproducibility

Read that page rather than a copy of it here. It also covers what the build sandbox already sets for you (SOURCE_DATE_EPOCH=0, PYTHONHASHSEED=0; the checker rejects setting either to 0 again), and how to verify: build twice, diff the two $OUTPUT_DIR trees. A tool that cannot use epoch 0 (e.g. jmod rejects pre-1980 dates) should get its date through its own option, not by re-exporting SOURCE_DATE_EPOCH: the checker does not catch a non-zero override, but it changes the date for every tool in the build.

Two things specific to this repo, which the public page does not carry:

  • Rust in this sandbox also needs -C linker=gcc. The build sandbox has no cc symlink, so the documented RUSTFLAGS must be extended, not used verbatim: RUSTFLAGS="-C linker=gcc --remap-path-prefix=$(pwd)=/builddir --remap-path-prefix=$HOME/.cargo=/cargo". See Rust build errors, cc not found in the FAQ.
  • A worked example of the wall-clock case lives here: packages/nspr/build.sh overrides the make variables its version header is generated from (SH_DATE from $SOURCE_DATE_EPOCH, SH_NOW= to omit the build time) rather than re-exporting SOURCE_DATE_EPOCH.

Preloading detbuild

packages/detbuild ships libdetbuild.so, an LD_PRELOAD shim for builds that leak process entropy or the host kernel version into what they ship (rustc and proc-macro HashMap order, LuaJIT's string hash, perl's Config). It replaces getrandom, getentropy, the raw getrandom syscall, arc4random and reads of /dev/urandom and /dev/random with a fixed-seed stream, and pins uname's release to 6.1.0-minimal. packages/detbuild/detbuild.c lists what it does not cover (AT_RANDOM, static and Go binaries, glibc-internal entropy).

Every byte it hands out is predictable, including the keys behind a TLS handshake made under it. So:

  • Add detbuild to build_deps. A build sandbox mounts only the dependency closure. Without the dependency, ld.so reports that the object cannot be preloaded and ignores it, so the command runs unpinned and the build still succeeds.
  • Preload one command: LD_PRELOAD=/usr/lib/minimal/libdetbuild.so cargo build --frozen --release. Never export LD_PRELOAD, and never set it through a .ncl env or vars.
  • Never on a command that uses the network. A build has the network when the package declares needs.internet or when any package in its build closure does (its build_deps and runtime_deps, and their runtime closures). Every package that build-depends on rust has the network this way, with no needs of its own. In such a build, fetch first without the preload (cargo fetch --locked, and any prebuilt download such as rusty_v8), then preload only the offline step (cargo build --frozen). A preloaded cargo always takes --frozen or --offline.
  • Never a sandbox default (gominimal/minimal#2227 proposed one).
  • The kernel version is fake. It is deliberately lower than any build host, so a version-keyed feature check can only turn a feature off. A build that needs a newer kernel feature should probe for the feature, not the version.

.github/scripts/detbuild_preload_guard.py enforces the first three rules on sources, and reads each build's closure from the evaluator's catalog dump. gominimal/minimal#2489 tracks rejecting libdetbuild strings captured into built outputs (libtool wrappers, configure caches).

Step 4: Validate

Run the config check against your package. This validates that the Nickel spec parses correctly and conforms to the schema.

Step 5: Build

Build the package on its own. Prefer the patched build: it wires dependencies to the most recent available build of each package, so editing a package deep in the graph does not trigger a long rebuild chain. The tradeoff is that those dependency builds come from cache and can be stale — run a full build when the result has to reflect a dependency's current sources.

If the build fails:

  • Check that all build dependencies are in build_deps
  • Check dependencies needed at runtime are in runtime_deps
  • Check that build.sh installs to $OUTPUT_DIR (not /usr/ directly)
  • Check the source URL and SHA256
  • Check that output globs match what build.sh actually installs
  • And keep iterating between the build and the check.

Step 6: Validate again

Some validation checkers run on the compiled output, and show up as skipped when a package hasn't been built yet.

Run the check again after a successful build so those checkers actually run, and iterate by fixing issues, rebuilding, and re-checking until all are addressed.

Troubleshooting FAQ

error: other: resolving dep '' by name: not found

A patched build can fail if one of its dependencies is not available locally, like this:

error: other: resolving dep '<package>' by name: not found

To fix it, make that package available locally by installing it, which forces a fetch.

If the missing package is one you are presently writing, installing it will fail because it does not exist upstream yet. Build that package first, so the completed build populates it locally, and only then move on to the packages that depend on it.

Rust build errors, cc not found

Sandbox doesn't have the standard cc symlink, so try adding:

export CC=gcc
export LD=gcc
export RUSTFLAGS="-C linker=gcc"

Go: disable sum database

No network access to sum.golang.org:

export GONOSUMCHECK=*
export GONOSUMDB=*

Python: install package to system

pip3 install --no-build-isolation --prefix=/usr --root=$OUTPUT_DIR .
0