The Ur Project

Documenting the craft of stacking things so they remain stacked. For a very long time.

recipe.toml reference

A recipe tells the build system how to turn one upstream source into packages. It's a directory with one file that matters, recipe.toml, and a few optional files next to it. This page describes all of it, key by key.

The same format is read everywhere. pkg build reads it on your own machine, the dispatcher reads it on a build host, and the designer writes, formats and lints it. If you want an easy start, the Building local packages guide walks through a first recipe, and The recipe format post explains why it looks the way it does.

Words like registry, repository, probe, subpackage and floor are explained in the glossary.

The recipe directory

A directory that contains a recipe.toml is a recipe. Nothing else is required, and the directory doesn't need to be part of a registry to be built.

sudo/
    recipe.toml          the recipe itself (required)
    patches/             patch files named by [[patch]] (optional)
        0001-install-pam.d-vendor-stack.patch
    pam.d/               an in-tree source named by [[source]] path (optional)
        sudo
        sudo-i
    upstream-key.asc     the upstream's OpenPGP public key (optional)
    overrides.lua        a Lua escape hatch (optional, rare)

Everything a build needs is in recipe.toml. The other files hold content that isn't TOML: patch diffs, an armored key, a script, or plain files that become part of the source tree. recipe.toml refers to them by name, and a name that points at nothing is an error.

The directory name is not used for anything. The package name comes from [recipe] name. By convention the two are the same, and in the official registries they always are.

Rules for the whole file

recipe.toml is plain TOML, but the loader is stricter than TOML itself.

Every key sits under a table header. There are no top-level keys. In TOML, a bare key written after an array of tables ([[source]], [[patch]]) quietly becomes part of the last element of that array. That's an easy mistake to make and a hard one to see, so the loader rejects any key that isn't under a header.

Unknown tables and keys are errors. A typo like [build_dep] or relase = 2 fails the parse instead of being ignored. This also means a recipe written for a newer build system fails loudly on an older one.

Tables appear in a fixed order. The formatter writes them in this order, and you should too:

#TableChanges
1[recipe]on every version bump
2[[source]]on every version bump
3[[patch]]sometimes
4[build_deps]rarely
5[rust]rarely
6[deps]rarely
7[probes]rarely
8[probe_args]rarely
9[build]rarely
10[check]rarely
11[classify]rarely
12[[permissions]]rarely
13[package]rarely
14[env]rarely

The order isn't cosmetic. Everything that changes on a version bump is at the top, so a bump is a small diff in one place. The upstream watcher, the designer and the formatter all write this order, so two tools never fight over the layout of the same file.

Each array of tables is one block. All [[source]] entries come together, then all [[patch]] entries together. TOML would let you spread them around the file, and it would still parse, but it's forbidden here.

Only [recipe] and at least one [[source]] are required. Every other table is optional, and most recipes use only a few of them.

Formatting

There's exactly one way to write a recipe. designer fmt <recipe-dir> rewrites a recipe into that form, and designer lint <recipe-dir> reports the differences without writing anything. Write it right the first time, or let the formatter do it, but don't fight it.

The rules:

  • Key order. [recipe] keys come in the order name, version, release, description, homepage, license_hint, summary, then anything else (arch). [[source]] keys come as name, url, sha256, then the rest (unpack_to, path). [[patch]] keys come as file, strip, stage. Keys in every other table stay in the order you wrote them.
  • One space around =. Never line up the = signs with extra spaces. Renaming one key shouldn't change the lines around it.
  • No indentation for keys. Keys start at the beginning of the line. Only the elements of a multi-line value are indented, 4 spaces per level.
  • String arrays have one element per line, indented 4 spaces, each with a trailing comma. That holds even for one element. An empty array stays as []. This way adding or removing a dependency is a one-line diff.
  • One blank line between tables, and none between keys inside a table.
  • Comments are kept. A comment stays with the table or key directly below it. Use comments to explain why: why a patch exists, why a flag is needed, which upstream bug a workaround is for.
  • Defaults are left out. If a key has its default value, the formatter removes it. These are the ones you'll see most often in older recipes:
KeyDefault, so leave it out
[[source]] unpack_to"."
[[patch]] strip1
[[patch]] stage"build"
[build_deps] needed, host_only[]
[rust] features[]
[deps] extra_requires, extra_conflicts[]
[check] enabledtrue

A table that ends up empty after that is removed too, unless it has a comment on it.

Plenty of recipes in the current registries still spell these defaults out, or have other leftovers from before the formatter existed. Don't copy them. The examples on this page are all in the canonical form.

[recipe]

The identity of the recipe. Required.

[recipe]
name = "hello"
version = "2.12.3"
release = 1
description = "Prints a friendly greeting"
homepage = "https://www.gnu.org/software/hello/"
license_hint = "GPL-3.0-or-later"
summary = """
The GNU Hello program produces a familiar, friendly greeting. It
allows non-programmers to use a classic computer science tool which
would otherwise be unavailable to them.
"""
name = "<string>" (required)
The package name. It's copied as-is into every package manifest the build produces, so it's the name that pkg install and pkg search see. Only A-Z, a-z, 0-9, _, + and - are allowed. There's no ., because a dot would split the name when it's used as a TOML key somewhere else. A name that has a version in it uses a dash: pkg-repo-buildroot-6-4, not pkg-repo-buildroot-6.4. A recipe that packages a Rust library crate from crates.io is named rust-<crate>.
version = "<string>" (required)
The upstream version, as upstream writes it: 1.9.17p2, 2.12.3, 51.0. It can't be empty, and it can't contain whitespace, control characters or /. It should also appear in the url of at least one source, because the lint uses that to catch a bump that changed one but not the other.
release = <integer> (required)
Starts at 1. Increase it by one whenever the recipe changes but the upstream version doesn't: a new patch, a new configure flag, a dependency change. Set it back to 1 when version changes. This works the same way as Release in an RPM spec file. A build host won't publish the same name, version and release twice, so a change that forgets the release bump doesn't get published.
description = "<string>"
One line of text for the person who reads pkg info or pkg search output. Say what the software does for them, plainly: "GNU Compiler Collection", or "Linux kernel module loading utilities (modprobe, insmod, rmmod, depmod, lsmod)". Don't put build notes here. That means no "GPG-verified", no checksums, no "needed by cmake", no "canonical release of X". Those go in a comment next to the source, or in the commit message. The lint warns when it's missing or when it reads like a build note.
homepage = "<string>"
The project's home page, as a full https:// URL. This isn't the download URL, which goes in [[source]]. The lint warns when it's missing, except for a recipe that only has in-tree sources, because a package that only exists in this project has no upstream home page.
license_hint = "<string>" (required)
The license of the software, as an SPDX expression: "GPL-3.0-or-later", "ISC AND BSD-3-Clause", "GPL-3.0-or-later WITH GCC-exception-3.1". The loader rejects a recipe without one. Use current SPDX identifiers (GPL-2.0-or-later, not GPL-2.0+ or GPLv2+), or the lint warns. For a license that SPDX doesn't list, use LicenseRef-<name>, which is part of SPDX for exactly this reason. It's a hint because the build also scans the source for license texts on every build, and may propose a correction. The content review post shows what that looks like.
summary = "<string>"
A longer description, as a multi-line string ("""). Same audience and same rules as description: what the software does, never how it was built. It's the only multi-line value in [recipe], which is why the formatter puts it last.
arch = "<string>"
Leave this out. The build works out the architecture of each subpackage from the files in it. Only set it for a recipe that can only ever be built for one architecture. No recipe in the official registries sets it today.

description, homepage and summary are copied into the [meta] table of every package manifest the build produces, so they show up on every system that installs the package.

[[source]]

Where the source code comes from. At least one is required. Each [[source]] is either remote, which means it's downloaded and checked, or in-tree, which means it's copied out of the recipe directory.

# Remote: downloaded, checked against sha256, unpacked.
[[source]]
name = "sudo"
url = "https://www.sudo.ws/dist/sudo-1.9.17p2.tar.gz"
sha256 = "4a38a1ab3adb1199257edc2a7c4a2bd714665eb605b04368843b06dada2cfcfb"

# In-tree: copied from pam.d/ in the recipe directory.
[[source]]
name = "pam.d"
path = "pam.d"
unpack_to = "pam.d"
name = "<string>" (default "main")
A label for the source. It shows up in build logs and in the provenance record. Two sources in one recipe can't have the same name, and a source without a name counts as "main". Most recipes name their first source after the package.
url = "<string>"
Where to download a remote source, as an http:// or https:// URL. Always comes with sha256.
sha256 = "<string>"
The SHA-256 of the downloaded file, as 64 lowercase hex characters. This is what makes a download safe to use: the file is checked against it before anything else happens, and a mismatch stops the build. Always comes with url.
path = "<string>"
For an in-tree source: a directory inside the recipe directory, as a relative path. It can't start with /, and it can't contain .. or empty segments. The directory must exist. A source has either url and sha256, or path, but never both.
unpack_to = "<string>" (default ".")
Where the source lands, relative to the top of the build tree. Same rules as path: relative, no ... The default, ".", puts it at the top. Leave it out when that's what you want.

How a remote source gets unpacked

Downloads go into a cache that's addressed by SHA-256. When the cache already has a file with the right hash, nothing is downloaded at all. A download larger than 1 GiB is refused.

The build looks at the first bytes of the file to decide what it is. It doesn't trust the file name or the URL, because upstream file names are often wrong. It handles tar with gzip, xz, bzip2 or zstd, and zip. Anything else, like a bare .c file or a plain .tar, is an error. If you need a loose file, put it in an in-tree source instead.

When every file in a tar archive sits under one top-level directory (sudo-1.9.17p2/...), that directory is stripped, the same way tar --strip-components=1 or %setup in an RPM spec file works. So the contents of sudo-1.9.17p2/ end up directly in unpack_to. Zip archives are never stripped.

Unpacking is safe against hostile archives. Absolute paths, .. paths, and links that point outside the target are all rejected, and a file that expands to more than about 100 times its compressed size is treated as a decompression bomb.

None of this happens inside the build sandbox. Sources are downloaded and unpacked first, and the build itself runs without any network.

How an in-tree source works

An in-tree source is copied, not unpacked, so it's just a directory of plain files. Use it for configuration files, small scripts, and for packages that only exist in this project, like filesystem or os-release, where there's nothing to download.

An in-tree source has no sha256 key, but it still has a hash. The build computes one from the file names, exec bits, contents and symlink targets in the directory, sorted, so it's the same on every machine. Changing any byte in the directory changes the hash, just like it would for a tarball.

Secondary sources

A recipe can have as many sources as it needs. There's no main source with extras bolted on: they're all equal, and they're fetched and unpacked in the order they're written. Two things make the first one a little special, though:

  • A registry can pin a recipe to a version and a SHA-256 in its registry.toml. That pin is checked against the first source.
  • The version lint only needs the version to appear in one source URL, so it usually matches the first one.

So put the upstream tarball first, and the rest after it.

You'll see secondary sources used for three things:

Files that the recipe adds to the tree. sudo and linux-pam both ship a pam.d directory as an in-tree source, unpacked into pam.d/ inside the build tree. A patch then changes the upstream install rules so make install also installs those files. The source puts the files in the tree, and the patch makes the build use them.

More upstream tarballs. Some projects need parts of another source tree in a fixed place. LLVM is the clearest case: it publishes one tarball per sub-project, and lld can't build without the shared CMake modules and a header from libunwind. Each one gets its own [[source]], with its own url and sha256, and an unpack_to that gives it a directory of its own:

[[source]]
name = "lld"
url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-21.1.8/lld-21.1.8.src.tar.xz"
sha256 = "d9524c5ee952500a2af92c27042a0d90ab089962af47816d4c85d0ebf76373d1"

# The shared CMake modules, found through LLVM_COMMON_CMAKE_UTILS.
[[source]]
name = "cmake-common"
url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-21.1.8/cmake-21.1.8.src.tar.xz"
sha256 = "85735f20fd8c81ecb0a09abb0c267018475420e93b65050cc5b7634eab744de9"
unpack_to = "cmake-common"

# Only for mach-o/compact_unwind_encoding.h.
[[source]]
name = "libunwind"
url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-21.1.8/libunwind-21.1.8.src.tar.xz"
sha256 = "03e8adc6c3bdde657dcaedc94886ea70d1f7d551d622fcd8a36a8300e5c36cbc"
unpack_to = "libunwind"

[probe_args]
cmake = [
    "-DLLVM_COMMON_CMAKE_UTILS=/build/cmake-common",
]

[env]
CXXFLAGS.append = "-I/build/libunwind/include"

The leading directory of each tarball is stripped as usual, so the CMake modules end up in cmake-common/, not in cmake-common/cmake-21.1.8.src/. Inside the sandbox, the top of the build tree is /build, so that's how [probe_args] and [env] point at the secondary sources. No patch is needed, because the build system already has a setting for where to find them.

Before you add a secondary source, check that it shouldn't be a package of its own. If the other project can be built by itself, it should be its own recipe, listed in [build_deps]. Then it's built once, gets its own security updates, and doesn't hide inside another package.

Every source is checked and recorded the same way. Each one is listed by its SHA-256 in the signed provenance record that comes with the build, and pkg provenance verify shows them all.

[[patch]]

The patches to apply to the source tree, in order. Leave the table out if there are none.

# Ship the PAM stack in /usr/lib/pam.d instead of /etc/pam.d.
[[patch]]
file = "0001-install-pam.d-vendor-stack.patch"
stage = "pre-probe"

[[patch]]
file = "0002-fix-parallel-build.patch"
file = "<string>" (required)
The patch file name, relative to the patches/ directory of the recipe. file = "0001-x.patch" means patches/0001-x.patch. It can't be absolute or contain ...
strip = <integer> (default 1)
How many leading path components to strip, like patch -p1. A patch made with git format-patch or git diff has a/ and b/ prefixes, which is what the default of 1 expects. Leave it out unless the patch is different.
stage = "pre-probe" | "build" | "post-build" (default "build")
When to apply the patch:
StageApplied
"pre-probe"Right after the sources are unpacked, before the build system is detected.
"build"After the build system has been picked, right before configure.
"post-build"After the build and install have run.

Use "pre-probe" for anything that changes how the source is detected or configured: a patch to configure.ac, meson.build or Makefile.in, or a patch that adds a makefile for [build] makefile. In practice most patches in the registries use "pre-probe", and that's a safe choice when in doubt. "build" is fine for a plain source code fix. Nothing uses "post-build" today. It changes the source tree after the install, and the package is made from the installed files, so a post-build patch doesn't change what ends up in the package.

The order of [[patch]] entries is the order they're applied in. This is the one place in the file where order means something. Within a stage, patches apply top to bottom. The stage is a filter, not a sort: a "pre-probe" patch listed after a "build" patch still applies first, because its stage runs first. Add new patches at the end. Don't reorder them to make a diff look tidy, because that changes the build.

Each patch is applied with patch(1), first as a dry run. If the dry run fails, nothing is written. If any patch fails, the patches already applied in that stage are reversed, and the build stops with the output of patch. There's no fuzzy fallback and no .orig or .rej files.

Some guidelines for the patch files themselves:

  • Name them NNNN-short-description.patch, numbered in the order they appear in [[patch]].
  • Make them with git format-patch when you can, so the header says who wrote it and why. Put a link to the upstream bug or merge request in the message.
  • One change per patch. A patch that does three things has to be redone three times when one of them goes upstream.
  • Every file in patches/ should be listed in [[patch]]. The lint warns about a patch file that nothing uses, and it's an error when [[patch]] names a file that isn't there.

Patches are part of what gets reviewed for each change. They show up as their own section in the review panel.

[build_deps]

The packages that need to be installed in the build sandbox.

[build_deps]
needed = [
    "linux-pam",
]
host_only = [
    "autoconf",
    "automake",
]

In this table and in [deps], <PACKAGE> is either package or package/sub: a package name, like "expect", or a package name and one of its subpackages, like "expect/bin".

needed = ["<PACKAGE>", ...]
Packages the build compiles or links against. They're installed in the sandbox, including their headers and other dev files, and each one also becomes a runtime dependency of the packages this recipe produces. A needed package must be built before this recipe can be.
host_only = ["<PACKAGE>", ...]
Tools that only run during the build, like autoconf, bison, cmake or python. They're installed in the sandbox, with their dev files too, but they don't become a runtime dependency. They still decide build order, except when two recipes need each other (a compiler that needs itself to build, for example). Then the order relaxes so the cycle can be broken.

Write package names only, without a version. Versions come from the repository the build resolves against, so the whole set builds against one consistent set of versions. Don't list anything that's part of the build floor, like the compiler, make or the C library. They're always there, and the lint warns when you list them anyway. The exception is a library whose headers you need: the floor only carries the runtime parts of a package, so naming a floor library in needed pulls in its dev files.

Runtime dependencies on shared libraries are found automatically, by reading the DT_NEEDED entries of every ELF file in the package. You don't list them anywhere. [build_deps] is only for what the build needs.

[rust]

Controls for Rust crates. Only recipes that build Rust code use this table.

[rust]
crate_type = "cdylib"
default_features = false
features = [
    "gtk4",
]

[rust.cdylib]
name = "glycin-2"
soversion = "0"
features = ["<string>", ...]
Crate features to turn on. They're added to the crate's default features, unless default_features = false.
default_features = <true|false> (default true)
Whether the crate's default feature is on. Set it to false for a build with --no-default-features.
crate_type = "lib" | "bin" | "cdylib"
Forces what kind of crate gets built. "lib" builds only the Rust library, for a crate that has both a library and a program, where the program is packaged by a separate recipe. "bin" builds the program. "cdylib" builds a shared library with a C interface, /usr/lib/lib<crate>.so, for a crate whose exported C functions are the actual product (librsvg's C library is built this way). Leave it out to let the build decide.
[rust.cdylib]
Gives a cdylib a versioned name, the way cargo-c or meson would. Only allowed together with crate_type = "cdylib". It has two keys, both required and non-empty: name, the library name without the lib prefix, and soversion. With name = "rsvg-2" and soversion = "2", the library is installed as librsvg-2.so.<crate-version>, with DT_SONAME set to librsvg-2.so.2, and the usual librsvg-2.so.2 and librsvg-2.so symlinks next to it.
crate_overrides = [{ name = "<string>", version = "<string>", reason = "<string>" }, ...]
Pins a dependency crate to a specific version instead of the one the lock file would pick: for a yanked crate, a security fix that isn't in the lock file yet, or a broken update. Each entry has name, version and an optional reason. Always give the reason. Nobody remembers it six months later.
[rust]
crate_overrides = [
    { name = "time", version = "0.3.41", reason = "0.3.42 breaks the build" },
]
[rust.dep_externs]
Maps a dependency's package name to the name it's imported as, for a crate whose library name is different from its package name. For example, the package new_debug_unreachable is imported as debug_unreachable. The recipe generator fills this in from Cargo's own metadata, so you shouldn't need to write it by hand.

How Rust code is packaged in general is described in the Packages post.

[deps]

Runtime dependencies that the automatic dependency scan can't find. Use it as little as possible. Shared libraries are found automatically, but a program that runs another program, or a library that loads a plugin at runtime, isn't. That's what this table is for.

# A Python app: it imports these at runtime, and no ELF file links them.
[deps]
extra_requires = [
    "gobject-introspection/lib",
    "pygobject/data",
    "pygobject/lib",
]

# glycin starts its image loaders as separate programs, so no
# library link points at them. Without this, nothing decodes.
[deps.subpackage_requires]
lib = [
    "glycin-svg/bin",
    "glycin-jxl/bin",
]
extra_requires = ["<PACKAGE>", ...]
Dependencies added to every runtime subpackage of this recipe. They're left off the subpackages that only hold documentation, translations, headers, debug info, license texts or source (doc, man, info, lang/*, dev, staticdev, debug, debugsrc, license, source), so installing a man page doesn't drag in a daemon.
extra_conflicts = ["<PACKAGE>", ...]
Packages that can't be installed at the same time as this one. Applies to every subpackage.
[deps.subpackage_requires]
Dependencies for one subpackage only. Each key in this table is written as below.
<sub> = ["<PACKAGE>", ...]
The left-hand side is the name of one subpackage of this recipe, like lib or bin. The dependencies on the right are added to that subpackage only. Here a <PACKAGE> without a / means another subpackage of this same recipe, at exactly the same version: lib = ["data"] makes <name>/lib need <name>/data. A <PACKAGE> with a / means a subpackage of some other package.

Apart from that one case, every <PACKAGE> in [deps] must be the package/sub form, like "expect/bin". A bare "expect" would point at something no package provides, so the dependency could never be satisfied. The lint treats that as an error. The program in a package is usually its bin subpackage, and the shared library is usually lib.

[probes]

Which probe builds the source. A probe recognizes one build system and knows how to drive it. Normally the build checks which probes apply to the source tree and picks the best one. The default order is:

cargo, meson, cmake, autotools, go, perl, pep517, setuptools, gradle, maven, yarn, npm, make, and last of all rustcrate.

[probes]
preferred = "rustcrate"
disabled = [
    "cargo",
]
preferred = "<string>"
Use this probe, even if a probe higher in the list also applies. It still has to apply. If it doesn't, or if it's also in disabled, the build fails. It doesn't fall back to the normal order, because the reason to set this is usually that the normal order picks the wrong build system.
disabled = ["<string>", ...]
Probes to skip, even when they apply. The next one in the list wins. A name that doesn't match any applying probe is ignored.

[probe_args]

Extra arguments for a probe, keyed by probe name. They're added to the end of that probe's configure command, as-is.

# configure looks for existing directories to find rundir and vardir,
# and the build sandbox has neither, so set them.
[probe_args]
autotools = [
    "--with-rundir=/run/sudo",
    "--with-vardir=/var/lib/sudo",
    "--with-pam",
]

The key is the probe name: autotools, meson, cmake, make and so on. Arguments for a probe that doesn't end up running are ignored, so it's safe to have arguments for more than one. Write one argument per element, not one string with spaces in it.

The distribution-wide defaults, like the install prefix, are already set by each probe. Only add what's specific to this package.

[build]

Settings for the build itself. Most recipes don't need any.

[build]
subdir = "libglycin"
timeout_secs = 14400
serial = <true|false> (default false)
Build without -j, so one job at a time. For a package whose build breaks or doesn't reproduce when it runs in parallel.
timeout_secs = <integer>
Time limit for each build phase, in seconds. The default is 2 hours. Only raise it for really big builds, like a compiler or a browser. Zero isn't allowed.
hardening = <true|false> (default true)
Whether the security hardening flags are added for C and C++ code: _FORTIFY_SOURCE, stack protection and RELRO. Only the core of the toolchain sets this to false, because glibc can't be built with _FORTIFY_SOURCE on itself. Don't turn it off to make a warning go away. Even with false, the CPU baseline (-march=x86-64-v2) and -fcf-protection=full still apply.
makefile = "<string>"
Build with a makefile that the recipe supplies, as make -f <name>, for both the build and the install. The file gets into the tree with a "pre-probe" patch. Setting this makes the make probe win, even when the source has a configure or CMakeLists.txt.
subdir = "<string>"
Build a subdirectory of the source instead of the top of it, for a tarball that holds several projects. Probe detection, the build and license scanning all use the subdirectory. The install still goes to the same staging tree. It has to be a relative path, without ...
seccomp = "fatal" | "softgap" | "errno"
What happens when the build calls a system call that the sandbox blocks. Leave it out to use the build host's default:
ValueBlocked system call
"fatal"Kills the process. Use it when a configure check must fail loudly instead of quietly detecting the wrong thing.
"softgap"Kills the process for the dangerous ones, returns ENOSYS for the rest.
"errno"Returns ENOSYS for everything. Use it for a test suite that would otherwise fill the host with core dumps.
license_scan_exclude = ["<string>", ...]
Paths in the source tree to leave out of license scanning, as globs. For a project that ships a collection of license texts that aren't its own license, like a license database library.
post_install = ["<string>", ...]
Extra shell commands that run after the probe's own build and install. Each one runs as sh -c '<command>' in the sandbox, at the top of the source tree (/build), without network, and with DESTDIR set to the staging tree. Use it to install files that the build system doesn't install: a header, a pkg-config file, a systemd unit from the source tree. Empty commands aren't allowed. Keep them short and simple. If you're writing a script, it probably belongs in a patch instead.
[build]
post_install = [
    'install -Dm644 contrib/foo.service "$DESTDIR/usr/lib/systemd/system/foo.service"',
]

Single quotes in TOML mean the string is literal, so $DESTDIR reaches the shell as written. Commands in post_install are part of the build, so a command that puts a time stamp or a host path into a file breaks the reproducibility check like any other build step would.

[check]

Controls the package's own test suite (make check, meson test, cargo test and so on). The tests run once, after the build has passed the reproducibility check. Their results are recorded but don't block the build. Most recipes don't need this table.

[check]
runner = "meson"
enabled = <true|false> (default true)
Set to false to skip the tests, for a test suite that hangs, is hopelessly flaky, or can't work in a sandbox without network. Add a comment that says why.
required = <true|false> (default false)
Reserved. Meant to make failing tests block the package in the future. It's recorded, but doesn't do anything yet. It can't be true while enabled is false.
command = "<string>"
Run this command instead of the probe's own test command.
args = ["<string>", ...]
Arguments for command.
cwd = "<string>"
The directory command runs in. Leave it out to use the same directory the default tests would use, usually the build directory. $build_dir and $src can be used in it. Only allowed together with command.
runner = "<string>"
The kind of test output to expect, instead of letting the build guess it. One of make, autotools, cmake, ctest, meson, cargo, autotest, dejagnu, gtest, prove or tap. With gtest, the GoogleTest programs that were built are run directly, instead of the build system's test command.

[classify]

The build splits the installed files into subpackages by path: programs in bin, shared libraries in lib, headers in dev, man pages in man, translations in lang/<language>, and so on. The default rules get it right for most packages. This table adds rules for the cases where they don't. The Intent, subpackages, and sets post explains why packages are split this way.

[[classify.prepend]]
pattern = "/usr/bin/vi"
target = "minimal"

[[classify.prepend]]
pattern = "/usr/share/vim/*/lang/<L>/LC_MESSAGES/**"
target = "lang"
locale = true

[[classify.prepend]]
pattern = "/usr/share/vim/*/**"
target = "enhanced"
[[classify.prepend]]

Rules that are checked before the default rules. For each file, the first rule that matches decides where it goes, so put specific rules before general ones. Each rule has:

  • pattern = "<string>" (required): the path to match. * matches anything within one path segment. ** as a whole segment matches any number of segments. <L> as a whole segment matches one segment and remembers it.
  • target = "<string>" (required): the subpackage the matching files go to. A new name, like minimal above, creates a new subpackage.
  • locale = <true|false> (default false): when true, the segment matched by <L> is added to the target, so lang becomes lang/de, lang/fr, and so on.
arch_neutral = ["<string>", ...]
Globs for files that look like machine code but are only data on this system: firmware for some other device, or a sample binary that the tarball ships. They no longer count when the build decides the architecture of a subpackage, so one firmware blob doesn't make a package of text files architecture-specific. /usr/lib/firmware/** is already included.
arch_specific = ["<string>", ...]
The other way around: files that don't look like machine code, but only work on one architecture, like a kernel inside a disk image. They make their subpackage architecture-specific.

A pattern in arch_neutral or arch_specific that doesn't match any installed file is an error, so a typo doesn't go unnoticed.

[[permissions]]

Sets the mode of an installed file, regardless of what make install did. This is how setuid and setgid programs are made.

# make install sets 4111 with chown root + chmod, which doesn't work
# in the build sandbox, so it would ship as 0755 without this.
[[permissions]]
path = "/usr/bin/sudo"
mode = "4111"
path = "<string>" (required)
The full installed path of the file, starting with /. Each path can only appear once.
mode = "<string>" (required)
The mode, as an octal string from "0" to "7777". A 0o prefix is allowed. Setuid, setgid and sticky bits work: "4755", "2755", "1777".

A path that doesn't match an installed file is an error. Upstream install rules that set the setuid bit almost always also try to chown root, which doesn't work in a sandboxed build. Write the mode down here, and it's always right.

[package]

Package-wide settings.

[package]
exclude = [
    "/usr/lib/bash/**",
]
exclude = ["<string>", ...]
Globs for installed files that should not be packaged at all. They're removed before the files are split into subpackages. This works like %exclude in an RPM spec file. Empty entries aren't allowed.

[env]

Environment variables for the build. Each key is a variable name, and the value is either a string or a table of edits.

[env]
# A string replaces what the probe would set.
CFLAGS = "-ffile-prefix-map=/build=. -g -O2"

# A table edits it instead.
CXXFLAGS = { erase = "-g", append = "-g1" }
LDFLAGS.append = "-Wl,--as-needed"

A string replaces the value the probe would otherwise set. It doesn't add to it. Repeat any of the defaults you want to keep. An empty string clears it.

A table edits the value. It has up to three keys, and they're always applied in this order, no matter how you write them:

  • erase: removes whole space-separated words that match exactly. erase = "-g" removes -g, but leaves -g3 alone.
  • prepend: adds text to the front.
  • append: adds text to the end.

At least one of them is needed. Values are used as written, so $VAR isn't expanded. A variable is either a string or a table, never both.

For CFLAGS, CXXFLAGS and LDFLAGS, the value that a table edits is the distribution default. For any other variable, it's whatever the probe sets, which is often nothing. Then append just sets the value.

Some flags can't be changed here. The CPU baseline (-march=x86-64-v2), the security hardening flags and -fcf-protection=full are added after [env] is applied, every time. Erasing them does nothing, and an empty CFLAGS doesn't remove them either. To turn off hardening, use [build] hardening. The baseline can't be turned off.

Some variables are reserved, because the build sets them to make it reproducible. Setting any of these is an error: LANG, LC_ALL, TZ, PATH, HOME, PWD, DESTDIR, RANLIB, SOURCE_DATE_EPOCH, GOAMD64, DISTRO_PKG_NAME, DISTRO_PKG_VERSION and DISTRO_PKG_RELEASE. Names can't be empty or contain =.

upstream-key.asc

Many upstream projects sign their release tarballs with an OpenPGP key. upstream-key.asc is that public key, ASCII-armored, as a file in the recipe directory.

-----BEGIN PGP PUBLIC KEY BLOCK-----

mQINBF...
-----END PGP PUBLIC KEY BLOCK-----

Here's how it's used. When the designer creates or updates a recipe, and an upstream-key.asc is present, it downloads the tarball and the detached signature next to it (<url>.sig), and checks the signature against the key. That has to succeed. A missing signature, a different key, or a key that's expired or revoked stops the recipe right there, instead of quietly falling back to just a checksum. Only after that does the sha256 get written into [[source]].

The result, meaning which key fingerprint signed which SHA-256 and when, is recorded, and it's included in the signed provenance record of every package built from that source. pkg provenance verify shows it for each source, as the key fingerprint and signature date, or as unsigned.

The build itself doesn't check the signature again. It only checks the sha256. That's on purpose. A signature check depends on the current date, because keys expire, so a build that works today could fail next year on exactly the same bytes. That would break rebuilding an old package to check that it's reproducible. The SHA-256 is fixed forever, and the signature check has already been done and recorded.

Some things to keep in mind:

  • Take the key from the upstream project itself: its website, its release announcements, or its KEYS file. Not from a random keyserver. Add a comment to the [[source]] with where it came from.
  • Only include the keys that actually sign releases. Upstream key files with a dozen old keys in them are common. Trim them.
  • When upstream changes its signing key, the new key needs the same review as any other change.
  • An upstream-key.asc in a recipe that only has in-tree sources does nothing. The lint warns about it, and also when the file isn't an armored public key.
  • No upstream-key.asc means checksum only. Lots of projects don't sign their releases, and that's fine. The sha256 still has to match.

There's one key file per recipe, and it's used for every remote source in it. The designer verifies a source when it writes its sha256, and designer attestation-gaps finds remote sources in keyed recipes that have no recorded signature yet, for example after a version bump, and verifies them. Signatures are fetched from <url>.sig. A secondary source from a different upstream, signed with a different key, can't verify against that one key, and stays a gap.

overrides.lua

For the rare package that the declarative tables can't describe, a recipe can include an overrides.lua script. It runs in a Lua 5.4 sandbox, with no io, os, require or file access. It gets a small set of functions, mostly to look at the installed file list and put files in subpackages:

source_tree()                 -- the list of installed files
detected_systems()            -- the probes that applied
emit_package(name, files)     -- put files in a subpackage
emit_warning(msg)             -- add a warning to the build log
emit_license(spdx, files...)  -- set the license for some files

Exactly one recipe uses it today. boost puts each compiled library in its own subpackage, by a name that's in the middle of the file name (libboost_system.so), which a path pattern can't pick out. Before you write one, check whether [classify] can do it. It usually can.

Lint checks

designer lint checks more than the formatting. These are the checks for mistakes that the loader can't see, mostly because they involve other files in the recipe directory. Errors block a recipe from being accepted, warnings don't.

CheckLevelWhat it catches
patch-missingerror[[patch]] file names a file that isn't in patches/.
patch-orphanwarningA file in patches/ that no [[patch]] uses.
version-driftwarningversion isn't in any source url. A half-done version bump.
placeholderwarningA source url or sha256 is still "TODO".
key-armorwarningupstream-key.asc isn't an armored public key.
key-orphanwarningupstream-key.asc in a recipe without remote sources.
homepage-missingwarningNo homepage, unless every source is in-tree.
description-missingwarningNo description.
description-provenancewarningdescription reads like a build note, not like a description.
license-invalid-spdxwarninglicense_hint isn't a valid SPDX expression.
deps-bare-packageerrorA name in [deps] without /subpackage.
rust-namewarningA crates.io crate in a recipe not named rust-*.
deps-restates-floorwarningA [build_deps] entry that the build floor already provides.
source-name-duplicateerrorTwo sources with the same name.

The loader checks the rest when the recipe is parsed, and a parse error always stops the build. For example: an empty name, a sha256 that isn't 64 hex characters, a source with both url and path, a reserved [env] key, or an unknown table or key.

A complete example

Here's sudo, a recipe with two sources, a patch, configure arguments and a setuid program. It's in the canonical form, so it passes designer lint as-is.

[recipe]
name = "sudo"
version = "1.9.17p2"
release = 8
description = "Run commands as another user under a configurable security policy"
homepage = "https://www.sudo.ws/"
license_hint = "ISC AND BSD-3-Clause AND BSD-2-Clause AND Zlib"
summary = """
sudo allows a permitted user to execute a command as another user,
normally root, under a configurable security policy.
"""

# Signed by the key in upstream-key.asc.
[[source]]
name = "sudo"
url = "https://www.sudo.ws/dist/sudo-1.9.17p2.tar.gz"
sha256 = "4a38a1ab3adb1199257edc2a7c4a2bd714665eb605b04368843b06dada2cfcfb"

[[source]]
name = "pam.d"
path = "pam.d"
unpack_to = "pam.d"

# Install the pam.d source into /usr/lib/pam.d.
[[patch]]
file = "0001-install-pam.d-vendor-stack.patch"
stage = "pre-probe"

[build_deps]
needed = [
    "linux-pam",
]

# configure looks for existing directories to find rundir and vardir,
# and the build sandbox has neither, so set them.
[probe_args]
autotools = [
    "--with-rundir=/run/sudo",
    "--with-vardir=/var/lib/sudo",
    "--with-pam",
]

# make install sets 4111 with chown root + chmod, which doesn't work
# in the build sandbox, so it would ship as 0755 without this.
[[permissions]]
path = "/usr/bin/sudo"
mode = "4111"

Next to it, in the recipe directory: patches/0001-install-pam.d-vendor-stack.patch, the pam.d/ directory with the sudo and sudo-i files, and upstream-key.asc.

The build detects autotools by itself, so there's no [probes] table. Linking against libpam gives the runtime dependency on linux-pam automatically, so there's no [deps] table either.