This file provides guidance for agents working with code in this 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:
- Run
minwith no subcommand for the authoritative list of what this session accepts. - Read https://minimal.dev/docs/reference/sandbox-operations for what each command does and what its flags mean.
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 asshellandclaude) 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.
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).
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/<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]pinninggominimal/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 updatere-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 reservesprofiles/<name>/profile.ncl.
Current tests: tls-trust (every TLS client refuses a distrusted CA, per distrust response).
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.
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",
},
},
} | BuildSpecNotice the general structure:
- Imports at the top
- Followed by variables, which can be used for string substitution or included / set elsewhere
- The
BuildSpecstructure, including the package name, the build dependencies, runtime dependencies, theSourcestructure(s) (which indicates where to get the source code, it will be made present in the cwd for the build), and anyLocalstructures, which also bring across adjacent files into the build cwd.
A Minimal package usually starts with a bunch of imports:
- Nickel types that need to be applied, such as
BuildSpec,Source,Localetc, these are imported from the magic 'minimal standard library' pathminimal.ncl. - Other packages needed for dependencies. These are simple lines that import the
build.nclfile that describes that package and assigns them to a variable. These can then be used as an element of thebuild_depsarray (if the package is needed during the build), or as an element of theruntime_depsarray, 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" inThe import line for types from minimal.ncl should:
- Be the first line in the file, and
- 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" inThe 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.
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.
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.
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_VERSIONSee 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" } | LocalThe 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.)
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,
},Metadata about the package can be captured in an attrs field. This is typed - it is not a free-for-all.
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,
},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".
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 = {},
},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.
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.
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:
- Injected.
ca-certificatessetsattrs.needed_for_internet. minimal adds every package with that attribute to the closure of any package that declaresneeds.internet. The injection then follows that package into the closure of everything that depends on it at runtime (minimalcrates/graph/src/transitives.rs,Transitives::new_with_seenset).curlandwgetdeclareneeds.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. - Declared.
ca-certificatesis listed inruntime_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.
-
Declare the store when you read it. If a package's binaries verify TLS against
/etc/ssl/certsat runtime and nothing in its runtime closure already brings inca-certificates, addca-certificatesto itsruntime_deps.ruby,typst,atuin,gwsandskopeodo this. -
Not in the toolchain closure.
python,perlandopensslare build inputs of nearly every package. Aca-certificatesedge on any one of them rehashes about 90% of the tree now, and again on every bundle update.opensslcannot take the edge at all, becauseca-certificatesbuild-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, addca-certificatesto that package. -
Do not use
needs.internetto 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/certsthrough 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 JDKcacertskeystore, Chromium's root store, gcloud's vendored bundles, and Rust crates that pinwebpki-roots. Switch them to the system store where upstream allows it. Where it does not, the package applies the denylist below, and itsbuild.nclsays 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 theca-certificatesbuild 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 inpackages/chromium-policies/ca-distrusted-certificates.jsonto be anspkiline here) and bypkgmgr truststores.cve-2026-111943-blocked-sha256.txt, exposed throughcve-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, becauseblocked.certsmatches 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-certificatesfrom a dated curl/Mozilla extract, append the response's block todistrust.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/certsneeds no edit; it gets the new bundle through its closure.
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.
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.
mkdir packages/<name>At minimum, a build spec needs:
namematching the directory namebuild_depswith a Local entry forbuild.shand a Source entry for the upstream archivecmd = "./build.sh"outputsdeclaring what the package produces
- Find the upstream release (GitHub releases, project website)
- Get the archive URL (prefer
archive/refs/tags/for GitHub) - 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 theurlandsource_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_fromrecords it;- it runs on the packaged
jdkand 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.
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,
},
} | BuildSpeclet { 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",
},
},
} | BuildSpeclet { 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",
},
},
} | BuildSpecThe 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.
#!/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#!/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#!/bin/bash
set -euo pipefail
cd repo-$MINIMAL_ARG_VERSION
export GONOSUMCHECK=*
export GONOSUMDB=*
go build -o $OUTPUT_DIR/usr/bin/my-tool .#!/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 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, notnode, forbuild_depsandruntime_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. Usenode-ltsfortest_depstoo. That part is convention only:minimal dumpdoesn'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 andusr/lib/node_modulesinto the user's session. - Install into
usr/libexec/<pkg>, neverusr/lib/node_modules. That tree belongs to the runtime's own npm. Expose each bin as a relative symlink, and name the private tree inoutputs. A package that builds with Node may not use a broad glob likeusr/**that would also captureusr/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, 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, inbuild_depsandruntime_deps.ruby-3.3andruby-3.4are there for users to opt into, likenode(Current); packages don't use them. An exception goes inVERSIONED_ALLOWEDin.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.4next toruby-lspfails. A user's own loadout isn't in the catalog, so the guard can't seeruby-3.4+ruby-lspchosen there.
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 toopenssl-sys0.9.117+. Useopenssl-3.5only when that isn't possible: a sealed or prebuilt binary that NEEDslibssl.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") tobuild_depsand 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) orOPENSSL_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 -dshould show the binary NEEDslibssl.so.3and neverlib(ssl|crypto).so.4(seepackages/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).
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 noccsymlink, so the documentedRUSTFLAGSmust be extended, not used verbatim:RUSTFLAGS="-C linker=gcc --remap-path-prefix=$(pwd)=/builddir --remap-path-prefix=$HOME/.cargo=/cargo". See Rust build errors,ccnot found in the FAQ. - A worked example of the wall-clock case lives here:
packages/nspr/build.shoverrides the make variables its version header is generated from (SH_DATEfrom$SOURCE_DATE_EPOCH,SH_NOW=to omit the build time) rather than re-exportingSOURCE_DATE_EPOCH.
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
detbuildtobuild_deps. A build sandbox mounts only the dependency closure. Without the dependency,ld.soreports 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. Neverexport LD_PRELOAD, and never set it through a.nclenvorvars. - Never on a command that uses the network. A build has the network when the package declares
needs.internetor when any package in its build closure does (itsbuild_depsandruntime_deps, and their runtime closures). Every package that build-depends onrusthas the network this way, with noneedsof 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 preloadedcargoalways takes--frozenor--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).
Run the config check against your package. This validates that the Nickel spec parses correctly and conforms to the schema.
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.shinstalls to$OUTPUT_DIR(not/usr/directly) - Check the source URL and SHA256
- Check that output globs match what
build.shactually installs - And keep iterating between the build and the check.
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.
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.
Sandbox doesn't have the standard cc symlink, so try adding:
export CC=gcc
export LD=gcc
export RUSTFLAGS="-C linker=gcc"No network access to sum.golang.org:
export GONOSUMCHECK=*
export GONOSUMDB=*pip3 install --no-build-isolation --prefix=/usr --root=$OUTPUT_DIR .