Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
pekit

Writing recipes

Single-page view · as markdown

Anatomy of a recipe

pekit / Writing recipes

A recipe is not a single file — it is a directory. One file, pekit.toml, is the recipe proper; the others beside it describe what to package, which environment to build in, and which secrets to sign with. This page walks through that file set, then focuses on pekit.toml itself and the one interface every recipe author leans on: the PEKIT_* environment variables that pekit hands to each target command.

The recipe directory #

Everything pekit needs to build one piece of software lives in a single directory. A rich recipe looks like this:

mypkg/
├── pekit.toml                 # the recipe: source, targets, env, wrap, delegate
├── package.pekit.toml         # base package: files, symlinks, dependencies, metadata
├── packages.pekit/            # (optional) extra package members, one file each
│   ├── docs.package.pekit.toml
│   └── dev.package.pekit.toml
├── env.pekit.toml             # (optional) reusable environment / wrapper
├── prod.keyring.pekit.toml    # (optional) named keyring for signing and secrets
└── ...
workspace.pekit.toml           # (optional) one level up — groups recipes into a workspace

Only pekit.toml is required. The rest are opt-in and each has its own page:

FilePurposePage
pekit.tomlThe recipe: where source comes from, how to build/test/install/clean it, shared env.this page
package.pekit.tomlThe base package: how staged output maps into a .peipkg, plus dependencies and metadata.Package files
*.package.pekit.toml, packages.pekit/Additional package members for a recipe that produces more than one package.Multi-package recipes
env.pekit.toml, *.env.pekit.tomlReusable environment variables and a shell wrapper to run commands under.Environments and keyrings
*.keyring.pekit.tomlNamed collections of secrets exported into the build as PEKIT_KEYRING_*.Environments and keyrings
workspace.pekit.tomlSits one level up and groups a directory of recipes into a workspace pekit can drive as a unit.Workspaces

pekit discovers the package, env, and keyring files by their fixed names in the recipe directory (packages.pekit/ is searched as an alternative location for package files). The base package file may live at package.pekit.toml or packages.pekit/package.pekit.toml, but not both. The supporting-files reference lists every name and location: Supporting files.

pekit.toml #

The recipe file is a TOML document with a small, fixed set of top-level keys. pekit is strict: any key it does not recognise is an error, caught when the recipe loads. The recognised keys are exactly:

out_dir, env, wrap, source, delegate, source_package, the four target sections build, test, install, clean, and the source-generation section gen.

The one pekit itself ships with is deliberately minimal:

out_dir = "out"

[build]
command = "go build -trimpath -o \"$PEKIT_OUT/pekit\" ./cmd/pekit"

[test]
command = "go test ./..."

[install]
command = "go install ./cmd/pekit"

[clean]
command = "go clean ./..."

A fuller recipe that fetches an external source and uses more of the surface:

# Where pekit stages its output. Managed by pekit; removable by `clean`.
out_dir = "out"

# Environment variables layered onto every target command.
[env]
CARGO_TERM_COLOR = "never"

# Wrapper: every target command runs inside this. {{command}} is substituted
# with the actual build script exactly once.
[wrap]
command = "nix develop --command sh -c {{command}}"

# Where the source tree comes from, and which versions exist.
[source.git]
url = "https://github.com/BurntSushi/ripgrep.git"
ref = "{{version}}"
versions = ">=13.0.0, <=14.1.1"
tag_regex = '^(\d+\.\d+\.\d+)$'

# Borrow selected behaviour from a pekit.toml inside the fetched source.
[delegate]
env = true

# Named build targets. `needs` names other build targets to stage first.
[build.main]
command = "cargo build --release && cp target/release/rg \"$PEKIT_OUT/rg\""

[build.completions]
command = "cp complete/_rg \"$PEKIT_OUT/_rg\""
needs = ["main"]

[test]
command = "cargo test --release"
needs = ["main"]

[install]
command = "cargo install --path . --root \"$PEKIT_OUT\""

[clean]
command = "cargo clean"

The top-level keys break down as follows:

  • out_dir — the directory pekit stages everything under, relative to the recipe unless absolute. Defaults to "out". This is pekit's managed output: pekit clean may remove it wholesale, so keep nothing precious there. It holds fetched sources, per-target staging directories, and written .peipkg files.
  • [env] — a table of NAME = "value" pairs exported into every target command. Names must be valid shell identifiers, and pekit reserves the PEKIT_ prefix — you cannot set a PEKIT_* variable here. See Environments and keyrings.
  • [wrap] — a single command that every target runs inside. The string must contain exactly one {{command}} placeholder, which pekit replaces with the target's script. Use it to enter a toolchain shell, a container, or a sandbox.
  • [source] — declares where the source tree comes from: [source.git], [source.url], or [source.local]. Absent, the recipe builds against its own directory. Covered in Sources.
  • [delegate] — opt in to borrowing build, env, wrap, or packages behaviour from a pekit.toml found inside the fetched source tree (or delegate = true for all of it).
  • [source_package] — opt out of, or rename, the corresponding-source package a recipe with a reproducible source emits alongside its binary packages. See Signing and provenance.
  • [build] / [test] / [install] / [clean] — the target sections. See below.

Targets are shell commands #

Each of build, test, install, and clean defines one or more targets. A target is fundamentally a shell command plus a little scheduling metadata:

  • command (required) — the command to run. A string is executed with sh -euc (POSIX shell, with -e abort-on-error and -u unset-variable-is-error). An array of strings is executed directly as a program and arguments, without a shell, unless a wrapper is in play.
  • needs — a list of other target names in the same section to stage first. Each named build a target needs is exposed to it as PEKIT_<NAME>_OUT (see below).
  • clear_out — whether pekit wipes this target's staging directory before running (default true).
  • dependencies — (build targets only) package dependencies to resolve and expose to the build. See Dependencies and claims.

A section can be written in bare form, with the keys directly under it (an implicit target named main):

[build]
command = "make"

…or in named form, one subtable per target:

[build.main]
command = "make"

[build.docs]
command = "make man"
needs = ["main"]

The two forms cannot be mixed within one section: a bare [build] with a command may not also carry a [build.docs] subtable.

The PEKIT_* environment contract #

This is the key interface between pekit and your build scripts. Before running a target, pekit stages a directory for it and exports a set of PEKIT_* variables describing where everything is. Your command reads them as ordinary shell variables — $PEKIT_OUT, $PEKIT_VERSION, and so on. The build writes its artifacts into $PEKIT_OUT; that staged directory is what the package files later draw from.

The target command runs with its working directory set to the source root ($PEKIT_SOURCE_ROOT) — except clean, which runs in the recipe directory.

The variables a recipe author reaches for most are these — a subset of the full contract:

VariableMeaningWhen set
PEKIT_OUTThis target's own staging directory. Write your build output here.Every target
PEKIT_RECIPE_ROOTDirectory containing pekit.toml.Every target
PEKIT_SOURCE_ROOTRoot of the source tree the command runs in (its working directory). Equals the recipe root when there is no external source.Every target
PEKIT_VERSION (and _MAJOR, _MINOR, _PATCH, _PRERELEASE, _BUILDMETA)The resolved version string being built, and its parsed components.When a version is resolved (absent for clean, gen, and verify, which never resolve one)
PEKIT_<NAME>_OUTThe staging directory of build target <NAME> named in this target's needs. <NAME> is upper-cased with - and . turned into _.Per entry in needs

The full contract — every managed variable, its exact value, and when it is set — is the environment contract in the command-line reference. It also covers the timestamp variables, the other root and output paths, and the PEKIT_DEPENDENCIES* variables that carry build dependencies into the command.

env.pekit.toml and keyring files layer more variables on top (PEKIT_KEYRING_*, plus any user env). User-set [env] values never override a PEKIT_* variable — that prefix is reserved.

Shell variables, not templates #

Inside a target command you use shell variables: $PEKIT_OUT, $PEKIT_VERSION. Pekit does not run its own templating over commands — they are handed to the shell verbatim (after the PEKIT_* exports are prepended). The {{...}} syntax you see in fields like source.git.ref = "{{version}}" is a separate, declarative template mechanism that applies to specific recipe fields, not to shell commands. Keep the two straight: declarative fields template with {{...}}; shell commands read $PEKIT_*. See Versions for the declarative side.

How pekit finds the recipe #

By default pekit walks up from the current working directory looking for pekit.toml, stopping at the first one it finds (the same upward search then looks for a workspace.pekit.toml above it). So running a pekit command anywhere inside a recipe tree finds the right recipe.

You can override this:

  • --recipe <path> points at a specific pekit.toml (or a directory containing one).
  • A remote locator — a github.com/... path, a .git URL, or a scheme://... URL, optionally with a @ref and a //subdir — makes pekit clone the recipe from a git remote before running it.

The full flag and locator surface is on the invocation page.

Where to go next #

  • Sources — the [source] section in depth.
  • Package files — turning $PEKIT_OUT into .peipkg payloads.
  • Recipe format reference — the exhaustive pekit.toml schema.

Sources

pekit / Writing recipes

A source tells pekit where a recipe's code comes from. Before any build target runs, pekit resolves the source into a concrete source tree on disk — the directory that targets execute in and that @source: package references read from. This page covers the source kinds, how each is materialised, and how a recipe can borrow behaviour from its source tree.

Sources are declared under [source.*] in the recipe. A recipe may have:

  • no external source (sourceless) — the recipe directory itself is the tree;
  • one reproducible source — either [source.git] or [source.url], never both;
  • optionally a local override — [source.local], on its own or alongside a reproducible source.

A source is reproducible when it is git or url — those pin to exact bytes. Declaring two reproducible tables is an error (mixed_source: "exactly one reproducible source table is allowed").

The three source kinds #

[source.git] #

Clones a git repository and checks out a ref. It takes exactly four fields — a required url (passed to git clone --mirror), a ref to check out (templated with the selected version; defaults to {{version}} and must render non-empty), a versions cap that filters enumerated or requested versions, and a tag_regex used when enumerating tags (see Enumeration). The field-by-field schema is in the recipe format reference.

[source.git]
url = "https://github.com/example/widget.git"
ref = "v{{version}}"
versions = ">=2.0.0"
tag_regex = "^v([0-9]+\\.[0-9]+\\.[0-9]+)$"

If ref renders empty (for example, a bare {{version}} with no version selected) pekit fails with missing_version — pass --version or set a non-templated ref.

Note

There is no submodule option. [source.git] accepts only the four fields above; a submodules key is an unknown_key error.

[source.url] #

Downloads a file over HTTP(S), optionally extracting it as an archive. The required url is templated with the selected version; extract (default false) treats the download as an archive, with root naming the subdirectory inside the archive to promote as the source tree; versions is a version cap (as for git); file_regex is used when enumerating a URL listing; and checksum pins the download to an expected digest — a single string or a per-version table (see below). The full schema is in the recipe format reference.

[source.url]
url = "https://ftp.example.org/widget/widget-{{version}}.tar.gz"
extract = true
root = "widget-{{version}}"
checksum = "sha256:0f1e2d..."

checksum is a sha256:<hex> string, and it is the only algorithm the verifier accepts — any other prefix fails with "unsupported checksum spec". It may instead be a table keyed by version:

[source.url.checksum]
"1.2.0" = "sha256:aaaa..."
"1.3.0" = "sha256:bbbb..."

When the table form is used and the selected version has no entry, pekit fails with missing_checksum. You rarely need checksum, though: every fetched version is pinned automatically in the recipe's lockfile, and a URL source is only materialised unanchored — not pinned to a digest — when it has neither a checksum nor a lock entry, which matters for reproducible packaging.

A URL source can also carry a [source.url.signature] table pinning the upstream maintainer's release-signing key: pekit fetches the detached signature beside the artifact and verifies it before anything is locked or built, closing the trust-on-first-use gap for brand-new versions. The full schema is in the recipe format reference.

[source.local] #

Points at a directory already on disk. Its single field is a required path — the directory to use as the source tree, resolved relative to the recipe directory.

[source.local]
path = "../widget-checkout"

[source.local] is an override, not a reproducible source: a recipe with only [source.local] cannot be pinned or enumerated. The local tree is only used when a local flag selects it — see below.

Selecting the local override #

Two invocation flags choose the local override; they resolve differently and are mutually exclusive (passing both is an error).

  • --local (strict): require a local source. If neither [source.local] nor --local=<path> supplies a directory, pekit fails with missing_local_source. Use this to force building from a working checkout.
  • --prefer-local: use the local source if it is usable, otherwise fall back to the reproducible source. If the local tree is missing and the recipe has no reproducible source, the fallback fails.

Both accept an inline path:

  • --local / --prefer-local with no =<path> uses the directory from [source.local] (resolved relative to the recipe directory).
  • --local=<path> / --prefer-local=<path> uses <path> resolved relative to the invocation's working directory (cwd), ignoring [source.local].

The chosen directory must exist and be a readable directory, or pekit errors. Local sources require an exact --version — --latest, --all-versions, and constraint selectors are rejected (unsupported_version_mode); with no version, a local build uses the placeholder 0.0.0-localdev.

For a sourceless recipe, passing a local flag is an unsupported_flag error — unless --allow-unused is set, in which case the flag is ignored with a warning and the recipe stays sourceless.

Materialisation #

Resolving a source produces a source state: its kind, the source root (where targets run), a provenance ref, and a timestamp. What pekit does to produce the tree depends on the kind.

Sourceless (recipe). No work: the source root is the recipe directory. Provenance is recipe:<recipe-root>.

Local (local). No copy or fetch: the source root is the local directory itself; targets run in place. Provenance is local:<path>. Because nothing is fetched, local sources are not cached and --refresh-source has no effect on them.

Git (git). pekit keeps a bare mirror clone under <out_dir>/_source_cache/git/<hash>/repo.git:

  1. If the mirror does not exist, git clone --mirror <url>; otherwise git fetch --prune --tags to update it. A failed refresh is fatal only when nothing pins what the ref means: if the selected version is locked, the entry matches the rendered ref, and the pinned commit is already in the mirror, pekit warns and builds from the locked commit instead — so a rate-limited or unreachable upstream cannot block a locked build. Unlocked resolves still fail hard.
  2. Resolve ref to an immutable commit (git rev-parse <ref>^{commit}).
  3. When a version is selected, verify the resolved commit against the version's lockfile entry — or create the entry on first resolve.
  4. Clone the mirror into <out_dir>/git-<hash>/source, then git reset --hard <commit> and git clean -fdx to pin the checkout.
  5. Apply the recipe's patch series, when one is declared. Reset and clean restored the pristine tree, so the series re-applies on every resolve.
  6. Write a source.pekit.json manifest recording the URL, ref, resolved commit, and provenance.

Provenance is git:<url>@<commit> and the timestamp is the commit's committer date, so a git build is anchored to a specific commit even when ref was a branch or tag.

URL (url). pekit caches the downloaded artifact under <out_dir>/_source_cache/url/<hash>/:

  1. If the artifact is not already cached, download it (with a pekit/2 user-agent).
  2. If a checksum is set, verify it; on mismatch, re-download once and verify again before failing.
  3. Verify the artifact against the version's lockfile entry, cache hits included — or create the entry on first resolve, verifying the upstream signature first when [source.url.signature] is configured.
  4. Materialise the tree at <out_dir>/url-<hash>/source: if extract is set, extract the archive to a temporary directory, then promote the root subdirectory (which must exist, else missing_source_root); otherwise copy the raw artifact into the source directory. Extraction preserves each regular file's archived mtime — release tarballs encode "generated outputs are newer than their inputs" in timestamps, and losing that fires autotools maintainer rebuild rules in environments without the maintainer tools.
  5. Apply the recipe's patch series, when one is declared. The series' content hash joins the materialisation scope, so an edited patch lands in a fresh extraction.
  6. Write a source.pekit.json manifest.

The downloaded artifact is cached regardless. The extracted tree is re-materialised from that cached artifact on each run when a checksum is set (the pinned content is deterministic anyway); without a checksum the tree is reused as long as the recorded manifest still matches.

Caching and --refresh-source #

Pekit does not expose configurable cache policies. Caching is exactly the behaviour above — a persistent raw cache (mirror repo / downloaded artifact) plus a materialised tree — and the one knob is --refresh-source, which discards the cache and rebuilds from scratch:

  • git: removes the mirror repo and the checkout scope, forcing a fresh clone --mirror and checkout.
  • url: removes the cached artifact and the materialised scope, forcing a re-download and re-extract.
  • local / sourceless: nothing to refresh.

A refresh does not relax the lock: the fresh download is still verified against the lockfile, so --refresh-source is how you check upstream and pekit lock --repin is how you accept a change.

The lockfile #

Fetched inputs are pinned trust-on-first-use in a machine-written pekit.lock beside pekit.toml. The first time a source resolves for a version, pekit records what it fetched — the artifact's SHA-256 for a url source, the resolved commit for a git source — and every later resolve verifies against that entry instead, cache hits included. A mismatch is a hard lock_mismatch stop: upstream's published bytes (or a tag) changed under a version that was already pinned. Accepting such a change is an explicit ceremony, never automatic:

pekit lock --repin --version 1.2.0

Commit the lockfile with the recipe. Local sources, dry runs, and git sources without a selected version (a bare branch ref is a deliberately moving target) are never locked. The file's exact schema is in Supporting files; the lock command — including pre-locking versions without building — is in the command-line reference.

Source packages #

A recipe with a reproducible source automatically emits a corresponding-source package alongside its peipkg-format members — the pristine upstream artifact, the patch series, and the recipe files, verifiable against the committed pekit.lock — and every emitted manifest records the provenance of its inputs, recipe, and builder. Both are covered in Signing and provenance; opt out or rename with the recipe's [source_package] table.

Archive extraction #

Extraction is chosen by the artifact's filename suffix. Only these formats are handled; anything else is an unsupported_archive error:

SuffixHandler
.zipnative zip reader
.taruncompressed tar
.tar.gz, .tgz, .crategzip + tar (.crate is cargo's publish format — a plain gzipped tarball)
.tar.bz2, .tbz2bzip2 + tar
.tar.zstzstd + tar
.tar.xz, .txztar piped through the external xz -dc (requires xz on PATH)

Extraction is hardened: entries that escape the extraction root (absolute paths, .., or traversal through a symlinked parent), unsafe symlink targets, NUL bytes, unsupported entry types, and colliding entries are all rejected as unsafe_archive.

Patches #

A recipe can carry a patch series that pekit applies to the materialised tree, declared as a directory name under [source]:

[source]
patches = "patches"

The directory's series file lists patch files in apply order (one relative path per line; # starts a comment). pekit applies them with git apply — strictly, with no fuzz — immediately after the pristine tree is materialised and before any target runs, so targets always see the patched tree and @source: mappings package patched files. How that interacts with caching follows the source kind:

  • git sources re-apply the series on every resolve: the checkout is reset to the pinned commit and cleaned first, so an edited patch takes effect on the next run.
  • url sources materialise once per patch-set content: the series' hash joins the materialisation scope, so an edited patch extracts and patches a fresh tree.
  • local sources are never patched — a local tree is your own working state, often with the series already applied or mid-rework. pekit emits a warning and continues.

A failed hunk is patch_apply; a patch that matches nothing is patch_skipped; either aborts materialisation and leaves no half-patched tree behind. The series grammar and its validation errors are in Supporting files.

Patches are committed recipe content, so the lockfile is uninvolved: it pins fetched bytes, and the patch series is already under version control. The whole directory ships in the recipe's source package under patches/ — the shipped series is the applied series by construction.

Rebasing on a version bump is deliberately manual: a stale patch fails loudly, so materialise the new tree (pekit lock --version <v> is enough), fix the patch, and run again.

Enumeration #

Reproducible sources can list the versions they offer upstream; this feeds --latest, --all-versions, and constraint selectors (see Versions).

  • git: git ls-remote --tags <url>, with tags optionally filtered by tag_regex. If the regex has a capture group, group 1 is the version; otherwise an embedded MAJOR.MINOR.PATCH is extracted.
  • url: fetch the directory listing derived from the url template (the part before the first {{…}}, up to the last /), filter entries by file_regex, and extract versions from the matches.

Local and sourceless recipes cannot enumerate (version_enumeration_unavailable) — they require an exact version. The versions field on a git or url source acts as a cap applied to the enumerated (or requested) set; version selection itself is documented on the Versions page.

Source roots and provenance #

The source root is the directory targets execute in and the base that @source: file references resolve against. Every resolution records a provenance ref identifying exactly what was materialised:

KindProvenance ref
sourcelessrecipe:<recipe-root>
locallocal:<path>
gitgit:<url>@<commit>
url (checksummed)url:<url>#<checksum>
url (locked, no checksum)url:<url>#sha256:<hash>
url (no checksum, no lock entry)url:<url> (marked unanchored — reachable only in a dry run, since a real resolve locks)

For git and url sources the provenance is also written to a source.pekit.json manifest beside the materialised tree, and pekit emits it as a source event under --verbose.

Delegation #

A recipe can borrow behaviour from a pekit.toml that lives inside its materialised source tree, using the [delegate] table (or the shorthand delegate = true, which enables everything):

delegate = true

# or, selectively:
[delegate]
build    = true
env      = true
wrap     = true
packages = true

[delegate] has four surfaces plus all: build borrows the build/test/install/clean targets from the source's recipe, env its environment variables, wrap its command wrapper, and packages the package definitions discovered in the source tree; all enables all four. (The key table is in the recipe format reference.)

Delegation only happens when the source tree is a different directory from the recipe (so a sourceless or in-place local recipe never delegates), and, for build targets, only when a pekit.toml exists at the source root. The merge is additive with the base recipe winning: a delegated target is adopted only if the base recipe does not already define a target of the same kind and name, and adopted targets are tagged as owned by the source. Env, wrap, and package delegation are likewise gated on their corresponding [delegate] flags and only apply when the source tree differs from the recipe root.

This lets a thin Peios recipe wrap upstream software that already ships its own pekit recipe: point [source.git] at the upstream repo, set delegate = true, and reuse its build and packaging while overriding only what you need.

Where to go next #

For how enumerated versions are selected and rendered, read Versions.

For turning the materialised tree into package payloads, read Packaging files.

For the exhaustive [source] schema, read Recipe format reference.

Versions

pekit / Writing recipes

A pekit build is almost always about a version of some upstream software. The recipe declares where source of a given version comes from and how to name the packages it produces; the invocation decides which versions are in scope. This page covers the version grammar pekit accepts, the {{...}} variables that carry version components into your recipe, and how the --version, --latest, and --all-versions selectors resolve against a source.

Version forms #

A version is written as:

MAJOR[.MINOR[.PATCH]][-PRERELEASE][+BUILDMETA]

Only MAJOR is mandatory. Minor and patch are each optional but ordered — you cannot write a patch without a minor. The optional -PRERELEASE and +BUILDMETA tails may each contain digits, ASCII letters, dots, and hyphens.

Written versionmajorminorpatchprereleasebuildmeta
22
2.43243
2.43.12431
1.21.0-rc.11210rc.1
1.21.0+build.51210build.5
1.21.0-rc.1+bld1210rc.1bld

Anything that does not match this grammar is rejected with an invalid_version error. Comparison and ordering are numeric on major/minor/patch (a missing component counts as 0), with the prerelease string compared lexically to break ties.

Version template variables #

Six variables expose the parsed components of the selected version. They render anywhere pekit expands {{...}} templates in a recipe — most importantly in source refs and URLs, and in package version and metadata fields. (These six are the version-derived subset of the template engine; the complete variable table, including the multi-package-only {{multipack}}, is in Supporting files.)

VariableExpands to
{{version}}the version exactly as written
{{major}}the major component
{{minor}}the minor component
{{patch}}the patch component
{{prerelease}}the prerelease tail, or empty
{{buildmeta}}the build-metadata tail, or empty
[source.git]
url = "https://github.com/example/tool.git"
ref = "v{{version}}"

Two rules govern rendering:

  • Referencing a component the version does not have is an error. {{version}} fails when no version is selected at all; {{minor}} and {{patch}} fail when the selected version stops short of that component (for example {{patch}} against 2.43). {{prerelease}} and {{buildmeta}} are the exception — they render as empty text when absent rather than erroring.
  • Unknown variables are an error. Any {{name}} that is not one of the six above (or the multi-package {{multipack}} token) is rejected. There is no silent pass-through.

Important

Version variables do not apply to shell target commands. A command in a build/test/install target receives version data through the PEKIT_VERSION, PEKIT_VERSION_MAJOR, PEKIT_VERSION_MINOR, PEKIT_VERSION_PATCH, PEKIT_VERSION_PRERELEASE, and PEKIT_VERSION_BUILDMETA environment variables instead — see recipe anatomy.

Selecting versions #

Three mutually exclusive flags choose the version set. Supplying more than one is rejected with --version, --latest, and --all-versions are mutually exclusive.

FlagSelectsNeeds an enumerable source?
--version <selector>exact version(s), or a semver constraintonly for constraints
--latestthe single highest available versionyes
--all-versionsevery available versionyes
(none)an unversioned build (no {{version}} available)no

Enumerable sources are those pekit can list versions for: a [source.git] source (via git ls-remote --tags) or a [source.url] source (by fetching the listing directory). --latest, --all-versions, and any constraint require one; against a non-enumerable source they fail with selected source cannot enumerate versions.

--version as an exact selector #

The simplest form is one or more exact versions, comma-separated:

pekit build tool --version 2.43.1
pekit build tool --version 2.43.1,2.42.0

A comma-separated list without spaces is treated as a set of exact versions. When the source is reproducible and enumerable, each written version is matched against the versions the source actually publishes using the trailing-zero ladder described below.

--version as a constraint #

A --version value that contains a space or a comparison operator (<, >, =, <=, >=) is treated as a constraint and takes the enumeration path. Constraints filter the enumerated set:

pekit build tool --version ">= 2.40"
pekit build tool --version ">= 2.40, < 3.0"

Multiple constraints (comma- or space-separated) are ANDed together. The ~ and ^ operators are not supported and produce unsupported version constraint operator. A bare * matches everything.

--latest and --all-versions #

pekit build tool --latest          # highest available version only
pekit package tool --all-versions  # every available version

--latest picks the single greatest version after enumeration and filtering. --all-versions selects the whole set.

The trailing-zero ladder #

Upstreams are inconsistent about trailing zeros: one project tags v2.43.0, another tags v2.43, a third tags v2. So when you ask for an exact version against a reproducible, enumerable source, pekit does not demand a character-exact tag. It builds a ladder of candidates from your written version, most specific first, and picks the first candidate the source actually offers:

You writeCandidates tried, in order
2.43.02.43.0, then 2.43
2.0.02.0.0, then 2.0, then 2
2.02.0, then 2
2.43.12.43.1 only

Only trailing zero components are dropped — a non-zero patch or minor is never elided. The ladder is also skipped entirely when the version carries a prerelease or build-metadata tail: 1.21.0-rc.1 is only ever matched as written. If no candidate is available (or the source is not reproducible/enumerable), the version you wrote is used verbatim.

The ladder applies to the exact-selector path only. --latest, --all-versions, and constraints work on the enumerated set directly and do not ladder.

Source version caps #

A source can declare a hard ceiling (or floor) on the versions a recipe will ever build, independent of what the invocation asks for. Set versions under the source table:

[source.git]
url = "https://github.com/example/tool.git"
ref = "v{{version}}"
versions = ">= 1.0, < 3.0"

The cap uses the same constraint grammar as --version. It is applied on both paths:

  • On the enumeration path (--latest, --all-versions, constraints) the cap filters the available set before the selector runs, so --latest returns the highest version within the cap.
  • On the exact-selector path the cap filters the versions you named. If every named version falls outside the cap the build fails with selected exact versions were filtered out by source version cap.

An empty result after capping is always an error — a cap that excludes everything is treated as a misconfiguration, not a no-op.

Local builds #

When the source resolves to a local directory, there is nothing to enumerate. Local sources therefore require an exact --version; --latest, --all-versions, and constraints fail with local sources require an exact --version.

If you build locally without giving a --version at all, pekit stamps the build with the sentinel version 0.0.0-localdev so that {{version}} and the PEKIT_VERSION* variables still resolve. This makes local development builds work without inventing a version number, while keeping them clearly distinguishable from real releases.

Single-version commands #

build, package, and publish operate on many versions at once — pass --all-versions and pekit plans one build per version.

test and install do not. They require the selector to resolve to exactly one version; if it resolves to more, the command fails with test requires a single resolved version (or install requires ...). Use an exact --version or --latest for these commands.

Where to go next #

For where a selected version's source tree comes from, read Sources.

For how version components render into package names and metadata, read Packaging files.

For the full flag surface behind --version, --latest, and --all-versions, read Invocation and flags.

Packaging files

pekit / Writing recipes

A package definition answers one question: which files, from where, go where in the package? Everything else — metadata, dependencies, claims, multi-package fan-out — hangs off that payload. This page is about the payload itself: the [files] and [symlinks] tables, how source references resolve, and the rules that decide the final layout of the .peipkg.

Package definitions live in their own files, not in the recipe: package.pekit.toml (the base definition) and <name>.package.pekit.toml (named members). How those layer together, and how [multipack] expands one definition into many packages, is covered in Multi-package recipes. Manifest metadata, dependencies, and claims are covered in Dependencies and claims. This page assumes a single definition and concentrates on the file mapping.

The [files] table #

[files] maps sources to package destinations. The mapping direction is source on the left (the key), package destination on the right (the value):

[files]
":bin/hello"        = "usr/bin/hello"
"@recipe:default.conf" = "etc/hello/hello.conf"

The left side is a source ref naming what to copy; the right side is the path the file takes inside the package. Destinations are always relative (see Destination rules below).

Each value may also be a table, which adds an override flag:

[files]
"@recipe:layout.json" = { path = "usr/share/hello/layout.json", override = true }

path is the package destination (required; an empty destination is an error), and override — default false — exempts the entry from the package format's layout policy (see override). The bare-string form "src" = "dest" is exactly "src" = { path = "dest" }. The field-by-field schema is in the recipe format reference.

Source refs #

A source ref names where a file comes from. There are three families.

Build-output refs — target:path #

A ref containing a colon is a build ref. The part before the colon is a build target name; the part after is a path inside that target's staged output:

[files]
"tools:bin/hc" = "usr/bin/hc"   # from build target "tools"
":bin/hello"   = "usr/bin/hello" # empty target = "main"

:path is shorthand for main:path — an empty target name resolves to the target main. Referencing a build target in [files] automatically pulls that build into the package's build plan, so you do not have to list it separately.

Rooted refs — @source:, @recipe:, @workspace: #

A ref beginning with @<root>: is anchored to a named root rather than a build output:

PrefixRoot
@source:The materialised source tree (the checked-out / extracted source).
@recipe:The directory containing the recipe (and its package files).
@workspace:The workspace root. Used outside a workspace, this is an error (missing_workspace).
[files]
"@source:LICENSE"      = "usr/share/licenses/hello/LICENSE"
"@recipe:hello.service" = "usr/lib/systemd/system/hello.service"

The reference table of source-ref roots gathers every ref form — build refs, the rooted prefixes, and bare paths — in one place.

Plain paths are owner-relative #

A ref with no prefix and no colon is a plain path — and its meaning is set by the file that declared it, not by any global default. At decode time, pekit rewrites each plain path to a rooted ref based on the file's owner:

The ref was declared in…A plain path becomes…
a recipe-side package file@recipe:
a delegated source-side package file@source:
a workspace-side package file@workspace:

So in a recipe's own package.pekit.toml:

[files]
"README.md" = "usr/share/doc/hello/README.md"

"README.md" is normalised to "@recipe:README.md" — it resolves against the recipe directory. The identical line in a source-delegated package file would normalise to "@source:README.md" and resolve against the source tree instead. This normalisation is what lets the same package file mean the right thing whether it ships beside the recipe or is discovered inside a delegated source.

Refs that are already rooted (@…:) or already build refs (contain a :) are left untouched by this rewrite.

Note

The owner-relative rule also applies to the source path of [multipack] file enumeration and to every entry in excludes — both are normalised the same way.

Path cleaning #

The path portion of every rooted ref is cleaned: . and duplicated slashes are collapsed, and a path that escapes its root with .. (or is absolute) is rejected:

invalid_ref: "@recipe:../secrets" escapes its root

Globs #

A source ref's path may contain glob metacharacters — *, ?, or a [...] character class — and ** for recursive matching:

[files]
"@source:share/icons/*.png" = "usr/share/hello/icons/"
"tools:lib/**/*.so"         = "usr/lib/"

Rules:

  • Matching happens only within the ref's source root. A glob never reaches outside the root it is anchored to.

  • Matches are sorted, and a match already covered by a matched parent directory is dropped (the directory carries its contents).

  • By default a glob that matches nothing is an error:

    missing_payload: file source /…/share/icons has no matches
    
  • Placement is relative to the non-glob prefix. Everything up to the first segment containing a glob metacharacter is the base; matched paths are re-rooted under the destination from there. So share/icons/*.png → usr/share/hello/icons/ places foo.png at usr/share/hello/icons/foo.png, and lib/**/*.so preserves the sub-directory structure below lib/ under the destination.

A plain (non-glob) ref that names a directory copies the whole tree; empty directories are emitted as explicit entries (so a pure directory skeleton is not lost).

Excludes #

excludes prunes files after expansion. Each entry uses the same source-ref syntax as [files] (and is owner-normalised the same way), so an exclude is scoped to a root:

[files]
"@source:share/**" = "usr/share/hello/"

excludes = [
  "@source:share/**/*.tmp",
  "@source:share/internal/**",
]

An exclude is applied only to expansions that share its root; it is matched against each candidate with the same glob engine. Unlike [files], an exclude that matches nothing is allowed — it is a filter, not a required input.

Destination rules #

The right-hand side of a [files] entry (and every symlink destination) is a path inside the package. It must be relative and stay in-bounds:

  • No leading / and no .. — an absolute or escaping destination is rejected.
  • No NUL bytes; an empty destination is an error.
  • . segments and duplicate slashes are normalised away.

Directory vs single-file destinations #

For a single, non-glob match, the trailing slash on the destination decides the shape:

RefDestinationResult
":bin/hello""usr/bin/hello"Renamed to usr/bin/hello.
":bin/hello""usr/bin/"Placed as usr/bin/hello (basename kept).

For a glob or a multi-match source, the destination is always treated as a directory prefix and matched paths are re-rooted beneath it.

Collisions are errors #

Two payload inputs that resolve to the same package path is a hard error — pekit will not silently let one input overwrite another:

payload_collision: multiple package inputs map to usr/bin/hello: …

(Sibling checks catch two package instances writing the same artifact file, or emitting the same package name.)

override #

override = true exempts an entry from the package format's layout policy only. The peipkg format validates that non-override payload paths obey its layout rules; an override entry skips that validation, letting you place a file somewhere the policy would otherwise reject.

Override does not relax the safety rules above: destinations are still cleaned, still may not escape the package with .., and collisions are still errors. It only opts out of format-level layout policy.

[symlinks] #

[symlinks] adds symbolic links to the payload. Here the key is the package destination (where the link lives) and the value is the link's target text:

[symlinks]
"usr/bin/hello-latest" = "hello"
"usr/lib/libhello.so"  = { target = "libhello.so.1", override = true }

The target text is stored literally — it is the exact string the symlink points at and is not resolved through any source root. It may be relative or absolute as a link target; pekit only checks that it is non-empty and contains no NUL. The bare-string form is "dest" = "target"; the table form adds override, which behaves as for files.

The destination key follows the same destination rules as [files].

The result #

Packaging stages the resolved payload and writes one package artifact per package instance under the recipe's output directory (out_dir). The format is chosen by the package's format (defaulting to tar); a peipkg package emits a file named <name>_<version>_<architecture>.peipkg, with the name, architecture, and other manifest fields coming from [package]. A peipkg package must set [package].version, [package].architecture, and [package].license; a tar artifact carries payload only and is normalised for determinism (entry timestamps zeroed, ownership dropped).

The .peipkg on-disk format itself is documented in Package management.

Where to go next #

For the rest of the manifest — dependencies, provides, claims — read Dependencies and claims.

For splitting one recipe into several packages, read Multi-package recipes.

For the exhaustive key-by-key schema, read the recipe format reference.

Dependencies and claims

pekit / Writing recipes

A finished .peipkg carries a manifest that says what the package needs (its dependencies), what shared names it fills, and how it plugs into names other packages share. Pekit builds that manifest from three sources, in order of increasing automation:

  1. Declared package dependencies — [dependencies] and [optional_dependencies] you write in a package file.
  2. Derived dependencies — sonames and pkgconfig capabilities pekit reads out of the staged binaries at pack time, added on top of what you declared.
  3. Claims — the [claims] table, wiring a package's provides and dependencies into the peipkg "exactly one holder of a shared name" mechanism.

A separate, unrelated mechanism — build dependencies exported to the build command — is also covered here because it shares the word "dependency". It never touches the package manifest.

Declared package dependencies #

Package dependencies live in a package file (package.pekit.toml or a <name>.package.pekit.toml member), not in the recipe. Two tables produce them: [dependencies] for hard requirements and [optional_dependencies] for soft ones. Both parse identically.

[dependencies]
libssl              = ">= 3.0"
"libc.so.6"         = "*"
"pkgconfig(zlib)"   = ">= 1.2.11"

[optional_dependencies]
bash-completion = "*"

Each entry maps a name to a version constraint string. The name is a peipkg capability name — either a real package name or a virtual capability such as a soname (libc.so.6) or a pkgconfig(...) token. The constraint is any peipkg constraint expression (">= 1.2", "< 2", and so on). Both "*" and the long form below with the constraint omitted mean any version.

The table form: placing a dependency in a named root #

An entry may instead be a table, which is the only way to attach a placement root — the named root the dependency should be installed into, when it differs from the depender's own root:

[dependencies]
# short form: constraint only, depender's own root
zlib = ">= 1.2"

# table form: constraint + explicit root
[dependencies.libfoo]
constraint = ">= 3"
root       = "system.lib"

A dependency table accepts exactly two keys, constraint and root; any other key is an error. Omitting constraint matches any version. The root is a named root reference (dotted lowercase segments, e.g. system.lib), never a filesystem path — a / in it is rejected at build time. Roots are not carried for conflicts, which stay root-local. There is no in-string IN root sugar; the table is the only spelling.

These entries become the manifest's Dependencies and OptionalDependencies. How they are matched to concrete packages at install time — capability lookup, version satisfaction, root placement — is dependency resolution in peipkg; pekit only records the requirement.

Derived dependencies: ELF and pkgconfig scanning #

At pack time, after staging the payload and before writing the archive, pekit scans the files it is about to pack and adds derived provides and dependencies on top of whatever the package declared by hand. Two derivations run, both operating purely over the staged files:

  • ELF derivation — reads the built binaries and shared objects.
  • pkgconfig derivation — emits pkgconfig(<name>) capabilities from any .pc files in the payload.

The results are merged additively: a derived provide or dependency whose name already appears in the hand-declared set is skipped, so declaring something by hand always wins. Non-fatal problems (see below) are printed to stderr as warning: lines; they do not stop the build.

What ELF derivation detects #

For every ELF object in the payload, pekit reads two dynamic-section fields:

ELF fieldBecomesMeaning
DT_SONAMEa provides entrythe object's own ABI name, e.g. libfoo.so.3
DT_NEEDEDa dependency entryeach shared library the object loads

The whole soname is the capability name. ABI-version identity lives in the string and is matched by equality, so a dependency on libfoo.so.3 is never satisfied by libfoo.so.4 — a soname bump is a different capability.

Two refinements keep the result accurate:

  • Self-provided sonames are subtracted from the needs. A package that ships both a library and something that links against it does not end up depending on itself.
  • Symlinks are skipped. A -devel package's libfoo.so -> libfoo.so.3 development symlink is not opened; following it would read the target's DT_SONAME and mis-attribute the provide to the package shipping the alias. The real object is derived where it actually lives.

If a file whose name looks like a shared library (lib*.so, lib*.so.N…) carries no DT_SONAME, pekit warns that "nothing can depend on it" — such a library is unusable as a dependency target.

Derivation is entirely automatic and takes no configuration beyond the symbol-version policy below. In particular it has nothing to do with the dependency_provider / PEKIT_DEPENDENCIES* mechanism described later — that is a different feature that shares a word.

Symbol version policy #

By default a soname dependency is derived at soname granularity only: the name must match exactly, with no version constraint. That is always safe but coarse — it cannot express "needs at least the glibc that introduced GLIBC_2.34".

A workspace may opt specific sonames into finer, version-aware derivation through the [policy.symbol_versions] table in the workspace file. It maps a soname to the symbol-version token prefix whose tokens are numbered on the same scale as the providing package's version:

# in the workspace file
[policy.symbol_versions]
"libc.so.6"   = "GLIBC_"
"libfoo.so.1" = "FOO_"

For a soname listed here, ELF derivation is refined on both sides:

  • Consumer side — pekit reads the object's versioned symbol needs, takes the highest token carrying the prefix (e.g. GLIBC_2.34), and turns it into a >= 2.34 constraint on that dependency. Reserved, non-numeric tokens such as GLIBC_PRIVATE are ignored.
  • Provider side — the soname's own provides entry is stamped with the building package's own version, so consumers have something to match against.

A soname absent from the policy is derived at soname granularity only, as above. The policy is a distro-wide assertion and lives only in the workspace file — [policy] currently accepts just the one sub-table, and any other key under it is an error. Recipes and package files cannot set it. When no workspace policy is configured, every soname is derived at name granularity.

Build dependencies exported to the build command #

Distinct from everything above, a build target in a recipe may declare [build.<target>.dependencies.<provider>] — a set of dependencies grouped under one or more provider names. These never enter the package manifest. Instead, pekit renders them and hands them to the build command through the environment, so a build script can fetch or pin its build-time inputs.

[build.main]
command = "make"

[build.main.dependencies.peipkg]
gcc  = ">= 13"
zlib = "*"        # "*" means any version; a build dep constraint may not be empty

[build.main.dependencies.system]
gcc = "*"

dependencies is only valid on build targets. Each provider block maps a capability name to a non-empty constraint (use "*" for any). Names and constraints are templated, so {{version}} and the other version variables are available.

Which provider is active is chosen by the dependency_provider key in the selected environment file:

# in env.pekit.toml (or a named <env>.env.pekit.toml)
dependency_provider = "peipkg"

For every target run, pekit writes a JSON payload to $PEKIT_ROOT/.pekit/dependencies/<command>.<target>.json and exports three variables into the build environment (with no provider selected, PEKIT_DEPENDENCY_PROVIDER and PEKIT_DEPENDENCIES are empty strings and the payload still carries all_providers):

VariableContents
PEKIT_DEPENDENCY_PROVIDERthe selected provider name
PEKIT_DEPENDENCIES_FILEabsolute path to the JSON payload
PEKIT_DEPENDENCIESthe selected provider's deps, one name constraint per line

These three are part of pekit's managed environment; the full environment contract lists every exported variable.

The JSON payload carries the command, target, provider, the resolved version, the selected provider's dependencies, and all_providers (every declared provider block, so a script can inspect alternatives). If dependency_provider names a provider that a target with dependency blocks does not define, that target's run fails (missing_dependency_provider); a target with no dependency blocks at all ignores the selection. If no provider is selected, the exported dependency list is empty but the payload still lists all_providers.

Pekit does not resolve or install these — it only renders the declared list and passes it through. Consuming it is the build script's job. dependency_provider is a name selector, not a command or hook.

Claims #

A claim is how a package plugs into a shared filesystem name that at most one installed package may hold at a time — the peipkg mechanism where several packages can supply a name like /usr/bin/cc, but exactly one holder owns the real file and the rest defer. See claims for what this means at install time.

Important

Terminology: these are claims. Do not call them "roles" — in Peios operator documentation "role" is a different, reserved concept.

A package declares claims in the top-level [claims] table of a package file. It has two sides:

  • [claims.provides] — the provider side: slots a [provides] entry fills.
  • [claims.dependencies] — the consumer side: slots a dependency expects.

Under each side, the next key is the name of the provides or dependency entry the claim attaches to, and under that is one or more freely-named slots. Each slot is a table with just two possible fields:

FieldSet byMeaning
targetproviderthe holder file this package ships that the shared name resolves to
pathconsumer (or a provider default)the shared filesystem location — where the claim symlink is materialised

A worked example — a compiler package that offers to hold /usr/bin/cc, and a consumer that expects cc to exist:

# provider: gcc.package.pekit.toml
[provides]
cc = "1"

[claims.provides.cc.default]
target = "/usr/bin/gcc"   # a file this package ships
path   = "/usr/bin/cc"    # provider default for the shared name

# consumer: some-toolchain.package.pekit.toml
[dependencies]
cc = "*"

[claims.dependencies.cc.default]
path = "/usr/bin/cc"      # the shared name this package needs present

Claim path and target values are run through the template engine, so a recipe may parameterise them (an arch triplet in a library path, for instance) like any other manifest value.

At pack time pekit validates the provider side: every provider slot's target must name a file the package itself ships. A target pointing outside the package's own payload is a packaging error — the materialised symlink would dangle or point at another package's file — and all offenders are reported together. Consumer-side slots and provider slots that set only a path own no target and are exempt from this check.

Where to go next #

For the package files these tables live in, read Packaging files.

For the environment file that selects a build dependency provider, read Environments and keyrings.

For the workspace [policy] table that refines soname derivation, read Workspaces.

Multi-package recipes

pekit / Writing recipes

A recipe rarely produces exactly one artifact. The same build tree is often split into a runtime package, a -doc package, and a -dev package; a single build sometimes fans out into one package per locale, per font family, or per plugin. pekit expresses both shapes without repeating the build: member package files split one definition into several named packages, and the [multipack] block expands a single definition into many instances.

This page covers where package definitions live, how their layers merge, how you select which packages a run emits, and how multipack fan-out works.

Package files and layers #

pekit discovers package definitions from files whose names follow a fixed convention, relative to a root (the recipe directory, and — under a workspace or a delegated source — the workspace and source directories too). Discovery recognises two kinds of file:

KindLocationSelector
Basepackage.pekit.toml, or packages.pekit/package.pekit.tomlmain
Member<name>.package.pekit.toml, or packages.pekit/<name>.package.pekit.toml<name>

Both the root itself and a packages.pekit/ subdirectory are searched, so you can keep package files beside the recipe or gather them in one folder.

  • At most one base may exist. Defining package.pekit.toml in both the root and packages.pekit/ is an error (duplicate_package_base).
  • A member's selector is its filename with the .package.pekit.toml suffix removed. A file literally named package.pekit.toml is the base, never a member; an empty member name is skipped. Each member name must be a valid selector, and a name may not be defined twice across the two search locations (duplicate_package).

Every file becomes a layer. Each layer records its owner — recipe, workspace, or source — which fixes how bare payload references in that file are qualified: an unqualified file source or exclude in a recipe-owned layer is read as @recipe:, in a workspace-owned layer as @workspace:, and in a source-owned layer as @source: (already-qualified refs and target:path build refs are left untouched). See Package sources and refs for the ref grammar.

How many packages a recipe emits #

The base file is a shared foundation, not a package of its own once members exist. The rule is:

  • No member files — the base alone produces a single package with selector main. (With no base and no members at all, there is nothing to build: missing_package.)
  • One or more member files — the recipe emits one package per member. The base is merged underneath every member as shared defaults; it does not additionally produce a standalone main package.

So to emit a runtime package plus a -doc package you write two member files; the base carries what they share.

Merge and precedence #

For each emitted package, pekit stacks the applicable layers and merges them in order, later layers overriding earlier ones. The order is:

  1. workspace base
  2. source base (only when the recipe delegates packages to a materialised source and the source root differs from the recipe root)
  3. recipe base
  4. source member(s) with this selector
  5. recipe member(s) with this selector

Merge is field-level, not deep, and two fields behave differently:

  • Scalars and metadata (format, clear_out, [package] fields such as version, architecture, license, homepage, default_root) override only when the later layer sets them; an unset field leaves the earlier value in place.
  • builds, [files], [symlinks], excludes, [multipack], [publish], and the constraint maps (dependencies, provides, …) are replaced wholesale when the later layer defines them. In particular a member's [files] table does not merge key-by-key with the base's — if the member declares [files] at all, it replaces the base's [files] entirely. Put shared payload in whichever single layer owns it.

One special case governs names: a member that does not set its own [package].name does not inherit the base's name. Its name is cleared, which lets it fall back to the selector-derived default (see Instance naming below). The base's [package].name therefore only ever names the lone main package.

Selecting packages on the CLI #

Two commands select packages: package (stages the builds a package needs, then writes its artifacts under out_dir) and publish (does the same, then copies each artifact to a configured localdir). See Commands and targets.

Selection works like target selection:

  • Positional selectors name members: pekit package hello hello-doc.
  • No selector, one package — that package is used.
  • No selector, several members — the run is ambiguous and pekit lists the members (ambiguous_package).
  • --all selects every member.

--all cannot be combined with positional selectors; doing so is rejected before any work runs:

invalid_flags: --all cannot be combined with package selectors

An unknown selector reports the available members (missing_package). A selector may carry an :instance suffix to pick a single multipack instance — covered next.

Multipack: one definition, many instances #

A single package definition fans out into several packages with the [multipack] block. Each expansion is an instance, identified by a multipack value; the value is available to the definition through the {{multipack}} template variable. There are two enumeration modes.

Static enumeration #

List the values inline. [multipack] accepts exactly one key, enum; as an array it is a fixed list of values (each a valid selector, no duplicates, and the list may not be empty):

[package]
name = "fonts-{{multipack}}"
version = "{{version}}"
architecture = "any"

[multipack]
enum = ["serif", "sans", "mono"]

[files]
"build:share/fonts/{{multipack}}/**" = "usr/share/fonts/{{multipack}}/"

This one definition emits three packages: fonts-serif, fonts-sans, and fonts-mono, each packing its own subtree.

Enumeration from files #

To derive the value set from what the build actually produced, give enum a files sub-table with a path glob and a regex (both required):

[package]
name = "locale-{{multipack}}"
version = "{{version}}"
architecture = "any"

[multipack.enum.files]
path  = "build:usr/share/locale/*"
regex = '^(?P<value>[a-z]{2})$'

[files]
"build:usr/share/locale/{{multipack}}/**" = "usr/share/locale/{{multipack}}/"

pekit globs path, applies regex to each match's basename, and takes the captured text as the multipack value. The regex must expose the value through either a named (?P<value>…) group or a single unnamed capture group; anything else is rejected (invalid_regex). Values are de-duplicated and each must be a valid selector. If the glob resolves but nothing matches, that is empty_multipack.

Note the phasing: the path template is rendered with {{version}} only — {{multipack}} is not available while enumerating, because that is the very value being discovered.

The {{multipack}} variable #

{{multipack}} is available only in the package-instance phase — once a concrete instance and its value exist. That covers the rendered manifest metadata ([package] name, version, description, dependencies, provides, claims, …), the [files] source and destination templates, [symlinks], and the [publish] destination. Using {{multipack}} anywhere it is not bound — including a non-multipack definition or the enum.files path — fails:

{{multipack}} is not available in this context

{{version}} and its parts are available throughout; see Versions.

Selecting one instance #

A positional selector may target a single instance with member:instance:

pekit package fonts:serif      # only fonts-serif
pekit package fonts            # all instances of fonts

The :instance suffix is valid only on a multipack definition; applying it to an ordinary package is an error, as is naming an instance the enumeration never produces (missing_package).

Instance naming and artifacts #

Each instance is named from its rendered [package].name. When a definition has no name, the name defaults to the member selector, and for a multipack instance the value is appended: a nameless multipack member plugins with value png becomes plugins-png. The artifact filename follows the format: <name>_<version>_<arch>.peipkg for format = "peipkg", or <name>.tar for a plain tar package.

Across everything a single run emits, pekit checks that no two instances write the same artifact path (artifact_collision) or the same name/version/architecture (package_name_collision).

Dry runs and unresolved instances #

--dry-run plans without executing. Because a dry run does not actually run the build targets, a multipack that enumerates from build output (an enum.files path pointing at a build: ref) has nothing to glob yet: its instances cannot be resolved before the build exists. Rather than fail, pekit reports each such definition as unresolved:

unresolved  fonts  multipack enumeration found no instances for build:...

A dry run whose only shortfall is unresolved instances still succeeds — the enumeration will resolve for real once the build runs. (Similarly, when package discovery is delegated to a source that has not been materialised, a dry run reports the packages as unresolved instead of erroring.) A real run performs the builds first, so enumeration sees the produced files.

Worked example: runtime plus docs #

A recipe that emits two packages from one build, sharing metadata through the base:

packages.pekit/package.pekit.toml — the shared base:

format = "peipkg"
builds = ["main"]

[package]
version      = "{{version}}"
architecture = "x86_64"
license      = "MIT"
homepage     = "https://example.org/hello"

packages.pekit/hello.package.pekit.toml — the runtime member:

[package]
name        = "hello"
description = "The hello program"

[files]
"build:usr/bin/hello" = "usr/bin/hello"

packages.pekit/hello-doc.package.pekit.toml — the docs member:

[package]
name        = "hello-doc"
description = "Documentation for hello"

[files]
"build:usr/share/doc/**" = "usr/share/doc/"

pekit package --all writes both hello_<version>_x86_64.peipkg and hello-doc_<version>_x86_64.peipkg, each inheriting format, version, architecture, license, and homepage from the base. pekit package hello writes only the runtime package.

Where to go next #

For the [files], [symlinks], and metadata fields each member defines, read Packaging files.

For the package and publish commands and their selectors, read Commands and targets.

For driving the packages of many recipes at once, read Workspaces.

Environments and keyrings

pekit / Writing recipes

Every target pekit runs — a build, test, install, or clean command — executes inside an environment pekit assembles for it. That environment has three distinct layers: the managed PEKIT_* variables pekit sets automatically, the keyring values that carry secrets and keys, and the user variables you declare in [env] and env files. This page describes how those layers are built, how they compose and override, how [wrap] wrappers change the way the command is launched, and how keyrings resolve and inject values.

For the exhaustive list of managed PEKIT_* variables and what each one means, see the environment contract in the command-line reference; Recipe anatomy walks through the ones you will use most. This page covers everything you add on top of that contract.

The three layers #

When a target runs, pekit composes three maps and overlays them in a fixed order:

LayerSourceNaming
ManagedSet by pekit (recipe/source roots, output paths, version parts, dependency outputs)PEKIT_*
KeyringKeyring files and --keyring.x.y= literalsPEKIT_KEYRING_*
User[env] in the workspace, recipe, and selected env fileAny valid shell name

The overlay order is managed, then keyring, then user — so in principle a later layer overwrites an earlier one on a key collision. In practice the three namespaces are kept disjoint by hard rules:

  • User [env] may not set any variable beginning with PEKIT_. Doing so is a reserved_env error.
  • A keyring export that collides with a user [env] variable or with a managed variable is an env_collision error, not a silent override.

So the layers never actually collide: managed owns PEKIT_*, keyrings own PEKIT_KEYRING_*, and the user layer owns everything else.

The [env] block #

[env] declares plain environment variables as a TOML table. Each key is the variable name and each value is a string:

[env]
CC = "clang"
CFLAGS = "-O2 -pipe"
PREFIX = "/usr"

Keys must be valid environment-variable names — they match ^[A-Za-z_][A-Za-z0-9_]*$. A non-string value, or a name outside that grammar, is a parse error. Declaration order is preserved from the file.

[env] may appear in three places, each contributing to the user layer:

  • the workspace file (applies to every member),
  • the recipe (pekit.toml),
  • the selected env file (see below).

Composition and override #

Within the user layer the sources are applied in this order, and a later source overrides an earlier one on the same variable name:

  1. workspace [env]
  2. delegated source recipe [env], then that source's env file (only when the recipe delegates env — see Recipe anatomy)
  3. recipe [env]
  4. the selected env file's [env]

So a variable set in the recipe overrides the same variable inherited from the workspace, and the selected env file has the final say.

Shell expansion of [env] values #

For the common case — a shell target, or any target run under a [wrap] wrapper — pekit emits the user variables as export lines in a shell script that runs before your command, and it does not quote them. That means [env] values are expanded by the shell and may reference other variables:

[env]
PATH = "$PEKIT_OUT/bin:$PATH"
LD_LIBRARY_PATH = "$PEKIT_OUT/lib"

Managed PEKIT_* and keyring PEKIT_KEYRING_* values are exported before the user block, so [env] can build on them. (A bare argv command — an array-form command with no wrapper — receives the composed environment directly from the process, where these values are literal rather than shell-expanded. Keep [env] values literal if your target uses the array form without a wrapper.)

Env files and --env #

An env file is a supporting file that lives next to the recipe and carries an [env] block, a [wrap] wrapper, a dependency_provider, or any combination of the three. It lets you keep environment- or profile-specific settings out of the recipe proper. An env file must declare at least one of those keys; the only recognised top-level keys are env, wrap, and dependency_provider.

Which env file is loaded is controlled by --env <name>. The default is main, so env.pekit.toml is picked up automatically when present and silently skipped when absent. Passing --env none disables env-file loading entirely. Any other name selects a named profile such as release.env.pekit.toml (--env release), which must exist — a missing named file is an error.

# release.env.pekit.toml
dependency_provider = "peipkg"

[env]
CFLAGS = "-O3 -DNDEBUG"

For the exact env-file schema and the selection table, see Supporting files.

[wrap] wrappers #

A wrapper changes how the target command is launched: instead of running your command directly, pekit substitutes it into a wrapper command at a {{command}} placeholder. This is how you run a build inside a sandbox, a container shim, a nice/taskset prefix, or any other launcher.

[wrap] takes a single command, written either as a shell string or as an argv array. Exactly one {{command}} placeholder is required:

# String form — {{command}} appears once in the shell string
[wrap]
command = "firejail --quiet -- {{command}}"
# Array form — {{command}} must be a complete argument (never argv[0])
[wrap]
command = ["nice", "-n", "10", "sh", "-euc", "{{command}}"]

At run time pekit assembles the export prelude plus your target command into a script, and:

  • String wrapper: the script is shell-quoted and spliced in at {{command}}, then executed with sh -euc.
  • Array wrapper: {{command}} is replaced by the script; the wrapper's first element is the program that is executed.

Only one wrapper is active per run. Like [env], [wrap] may be declared in several places, and the last non-empty one wins, in this order (lowest to highest precedence):

  1. workspace [wrap]
  2. delegated source env file [wrap] (only when the recipe delegates wrap)
  3. recipe [wrap]
  4. the selected env file's [wrap]

So a recipe wrapper overrides the workspace's, and the selected env file's wrapper overrides the recipe's.

dependency_provider #

Env files may also set dependency_provider, a single string naming which of a target's declared [build.<target>.dependencies.<provider>] blocks is exported to the build environment (PEKIT_DEPENDENCIES, PEKIT_DEPENDENCY_PROVIDER, PEKIT_DEPENDENCIES_FILE). It is not an environment variable; it selects a declared provider block by name. When both a delegated source env file and the recipe's own env file set it, the recipe's env file wins. See Dependencies and claims for what the selection exports.

Keyrings #

Keyrings supply secrets and keys to targets. Every keyring value is exported to the target as a PEKIT_KEYRING_* environment variable. There are two ways to provide them: keyring files and inline literals.

--keyring=<value> (keyring files) #

--keyring names a keyring file and is repeatable. The value is resolved like this:

  1. If the value is path-like, it is treated as a path immediately. A value is path-like when it is absolute, begins with ., begins with ~, or contains a /. The path is resolved relative to the current directory; if the file does not exist, that is a missing_keyring error.
  2. Otherwise the value is treated as a name: pekit looks for <value>.keyring.pekit.toml in each search root in turn. For a plain recipe the search root is the recipe directory; inside a workspace the workspace root is searched first, then the recipe directory.
  3. If no named file is found in any search root, pekit falls back to treating the value as a path relative to the current directory; if that does not exist either, it is a missing_keyring error.

In a pekit workspace run, named keyrings are resolved once, against the workspace root, and the resulting values are shared with every member.

# Resolved by name → looks for prod.keyring.pekit.toml in the search roots
pekit build --keyring=prod

# Path-like → loaded directly
pekit build --keyring=./secrets/prod.keyring.pekit.toml

When several --keyring files are given, they are loaded in command-line order and each overlays the previous — so a later keyring file overrides an earlier one on the same key.

--keyring.<dotted.path>=<value> (literals) #

To inject a single value without a file, use --keyring.<dotted.path>=<value>. The dotted path is sanitised into an exported variable name — upper-cased, prefixed with PEKIT_KEYRING_; the exact sanitisation rules are in Supporting files.

pekit build --keyring.tcb.priv=<value>
# exported to the target as PEKIT_KEYRING_TCB_PRIV

Literals are keyed by their dotted path, so repeating the same path replaces the earlier value with the later one.

Precedence #

Keyring sources combine in this order, lowest to highest precedence:

earlier --keyring file  <  later --keyring file  <  --keyring.x.y= literal

That is: keyring files overlay in command-line order, and all inline literals are applied last, so a --keyring.x.y= value always overrides whatever a keyring file provided for the same exported name. Any resulting PEKIT_KEYRING_* name that would collide with a managed PEKIT_* variable or with a user [env] variable is an env_collision error.

Package signing #

One keyring entry is read by pekit itself rather than merely exported: signing.package_key names the Ed25519 private key that signs every peipkg-format artifact a package or publish run produces. Because it rides the keyring mechanism, the key path stays in a per-developer, gitignored file — or arrives inline as --keyring.signing.package_key=<path> — and private key material never enters a recipe. Without a key, publish refuses peipkg packages unless you pass --allow-unsigned. The exact behaviour and the accepted key encodings are in Supporting files.

Binary signing #

A build target's sign table also reads the keyring, but through entries the recipe names rather than a fixed one: "bin/peinit" = "tcb.priv" signs that output file with the key whose path the tcb.priv leaf holds. Any leaf will do; the convention in Peios' own recipes is [tcb] priv = "<path>". See Signing binaries for PIP.

Keyring file format #

A *.keyring.pekit.toml file is a TOML document whose leaves are strings. Each string leaf becomes one PEKIT_KEYRING_* variable, named from its full dotted key path using the same sanitisation as inline literals. Nested tables are flattened:

# prod.keyring.pekit.toml
token = "<value>"          # -> PEKIT_KEYRING_TOKEN

[tcb]
priv = "<value>"           # -> PEKIT_KEYRING_TCB_PRIV
pub  = "<value>"           # -> PEKIT_KEYRING_TCB_PUB

A leaf that is not a string is rejected, and typed entries — a sub-table carrying a path or content key — are reserved and currently rejected as unsupported_keyring_entry. The full schema is documented in Supporting files.

A note on secrets in output #

Keyring values are exported to the target like any other environment variable. pekit does not redact them from a target's stdout/stderr — if your command prints a secret, pekit streams it verbatim. pekit's own diagnostics and its --verbose environment summary log variable names only, never values, but you are responsible for not echoing secrets from inside your commands. Prefer writing secrets to files or consuming them directly rather than printing them.

Where to go next #

For the managed PEKIT_* variables these layers sit on top of, read the environment contract in the command-line reference.

For the exact env-file and keyring schemas, read Supporting files.

For the signing key that rides the keyring mechanism, read Signing and provenance.

Peios Learn — documentation for the Peios project.

Built with Trail.