Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail Ckclackatoo 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 = '^(?P<version>\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 it is an absolute path below the recipe root. Defaults to "out". Pekit rejects the recipe root, its parents, and paths outside it: this is pekit's managed output and pekit clean removes it wholesale. 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], [source.pypi], 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 — one of [source.git], [source.url], or [source.pypi], never more than one;
  • optionally a local override — [source.local], on its own or alongside a reproducible source.

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

When writing URL listing filters, remember that file_regex searches the downloaded listing text. Anchoring it with ^ and $ to a bare filename will not match that filename inside an HTML page. Use a named version capture when the package name itself contains digits, for example m4-(?P<version>[0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz, so those digits cannot be mistaken for the release version.

The four source kinds #

[source.git] #

Clones a git repository and checks out a ref. It takes five 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 fifth, tracked_path, selects the moving-ref snapshot mode described below. An optional [source.git.signature] table verifies the upstream's release signatures; see Signed Git releases. 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(?P<version>[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. A submodules key is an unknown_key error.

Signed Git releases #

Many upstreams sign their release tags. A [source.git.signature] table pins the signing maintainer's OpenPGP key, and pekit then requires a valid signature for every version it resolves:

[source.git]
url       = "https://git.kernel.org/pub/scm/libs/libtrace/libtraceevent.git"
ref       = "libtraceevent-{{version}}"
tag_regex = '^libtraceevent-(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$'

[source.git.signature]
key_files    = ["keys/steven-rostedt.asc"]
fingerprints = ["5ED9A48FC54C0A22D1D0804CEBC26CDB5A56DE73"]

By default the rendered ref must be an annotated tag with an OpenPGP signature. The tag object must name that same tag and point straight at the commit the ref resolved to, so a signed tag from another release can't vouch for this one. For an upstream that signs commits rather than tags, set object = "commit": the resolved commit must then carry the signature.

Verification is in-process, like URL signatures: no host gpg, no keyring, no Git signing configuration. It runs after the mirror fetch, which only stores objects, and before the lock is written or the tree is checked out. A lightweight tag, an unsigned tag or commit, or a signature pekit cannot read is signature_missing (or signature_unsupported for SSH and X.509 signatures). A bad signature is signature_invalid, and a valid signature from a key outside fingerprints is signature_untrusted_key. Any of these stops the run with nothing locked. Key expiry works the same way as for URL signatures, including ignore_expiry.

Unlike a URL signature, a Git signature is checked on every resolve, not only on the first. The signer's fingerprint goes into the version's lock entry, and a later resolve that finds the release signed by a different pinned key stops with lock_mismatch.

[source.git.signature] cannot be combined with tracked_path: a tracked snapshot follows a branch, not a signed release.

An upstream that signs nothing — lightweight tags, no signed commits — cannot use this table. The source.signature.required lint rule reports it, and the recipe records why the upstream can't be verified as a reasoned allowance in lint.pekit.toml. The exception is then a deliberate declaration, not a silent default.

Tracking one file on a moving ref #

Some upstream data has no release tags of its own: one file on a stable branch is the published stream. Set tracked_path to follow that file without turning every unrelated branch commit into a package release:

[source.git]
url = "https://github.com/example/upstream.git"
ref = "refs/heads/release"
tracked_path = "security/trust/certdata.txt"
versions = ">= 2026.01.01"

This is deliberately a bounded mode:

  • ref must be fixed and non-templated; tag_regex cannot be combined with tracked_path;
  • tracked_path is one clean repository-relative regular file, not a directory, symlink, or submodule;
  • discovery observes the fixed ref and compares the selected blob's SHA-256 with the newest matching lock entry. An unrelated commit, or any observation with the same bytes as that newest lock, creates no version;
  • a changed blob receives the UTC discovery date YYYY.MM.DD. Further changed blobs discovered and locked that day receive .2, .3, and so on. The suffix follows committed lock history, and a backwards wall clock clamps to the newest locked date, so the sequence remains monotonic;
  • the lock binds repository URL, fixed ref, path, immutable commit, Git blob object ID, and SHA-256 of the blob bytes. It is append-only for this mode: discover a change with --latest; --repin is rejected.

--latest, constraints, and --all-versions check the moving ref. The enumerated set is all matching historical lock entries plus the current changed blob, if there is one; --all-versions therefore retains history rather than replacing it with the tip. An exact locked version does not resolve or fetch the moving ref. It uses the pinned commit and path, works offline when those objects are cached, and may fetch only the pinned commit on a cold cache. An exact version not yet locked is accepted only when it is the date version Pekit computes for the currently observed changed blob.

The source root preserves the tracked relative path and contains no other upstream file. Targets therefore read the example above at $PEKIT_SOURCE_ROOT/security/trust/certdata.txt. This mode is suited to a single independently versioned data input; use ordinary git mode when the build needs a repository tree.

[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); listing_url can name a release page when the download template does not live in a browsable directory; file_regex is used when enumerating that listing. Like Git tag_regex, named version or component captures can map upstream filenames into the catalogue's version form; the URL and archive root then render from that mapped version. 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.

A signing key that expires later does not invalidate an immutable historical release: Pekit accepts the signature only when its signed creation time falls within the key's validity period. A present-time key revocation, an explicitly expired signature, a signature created after key expiry, or a materially future-dated signature remains a hard failure by default. For an upstream that knowingly continues to sign new releases after its key expires, a recipe may set ignore_expiry = true in the signature table. This is an explicit per-source exception to key expiry only: signature validity, the pinned fingerprint, revocation, explicit signature expiry, and timestamp checks remain mandatory.

Signed checksum manifests #

Some upstreams sign a checksum file rather than the artifact: a clear-signed text file listing each release file's digest. of = "checksums" verifies such a manifest instead of a detached signature. Dash is the case in the catalogue:

[source.url.signature]
of           = "checksums"
url          = "{{source_url}}.sha512sum"
key_files    = ["keys/herbert-xu.asc"]
fingerprints = ["9F9D45FE50AE361530983792C7271D0A49B18BA7"]

Pekit fetches the manifest from url, verifies its clear signature against the pinned keys, and then requires exactly one entry whose name is the artifact URL's file name, character for character (./dash-0.5.13.5.tar.gz or a directory-qualified name does not match). The artifact's digest must equal that entry's, and its size too when the entry states one. The signer is recorded as signature_key in the lock, as for a detached signature, beside the artifact's sha256.

Because one manifest signature vouches for every file the manifest lists, this mode is stricter than a detached signature:

  • url is required, since checksum manifests have no conventional name, and fingerprints is required, each a full key fingerprint.
  • The manifest must be a clear-signed message and nothing else; text outside the signed block is refused.
  • Three line forms are read: <digest> <name> (coreutils, with *<name> for binary mode), <digest> <size> <name> (Dash's), and SHA512 (<name>) = <digest> (BSD). Digests must be SHA-256 or SHA-512. Any line pekit cannot read, including an MD5 or SHA-1 digest, makes the whole manifest unusable rather than being skipped.

A manifest that fails any of these checks is checksum_manifest_invalid; an artifact that does not match its entry is checksum_mismatch. As with every signature failure, nothing is locked or built.

Some upstreams publish one major.minor archive and then maintain it as an incremental numbered patch series. [source.url.patch_series] models that scheme without fetching anything from a build script:

[source.url]
url = "https://ftp.example.org/widget/widget-{{version}}.tar.gz"
extract = true
root = "widget-{{version}}"
versions = ">= 5.3.0"
file_regex = 'widget-[0-9]+\.[0-9]+\.tar\.gz'

[source.url.patch_series]
url = "https://ftp.example.org/widget/widget-{{major}}.{{minor}}-patches/widget{{major}}{{minor}}-{{patch}}"
patch_width = 3
strip = 0

For a selected 5.3.15, the primary url, root, checksum lookup, and base signature render with base version 5.3; patch URLs render from 001 through 015. The resulting version is the base archive plus that ordered prefix. Enumeration exposes 5.3.0, 5.3.1, …, 5.3.15, so ordinary runs select the latest while --all-versions can reproduce the complete patchlevel history. The patch listing must be contiguous from 1; a missing number is url_patch_gap. A missing patch directory represents a new base release at patchlevel zero.

[source.url.patch_series.signature] accepts the same fields and has the same required-verification semantics as [source.url.signature]. Each patch is downloaded, verified, and recorded in the selected version's lock entry before that entry is written, so failure anywhere in the chain leaves no partial lock. file_regex is optional; when present it must contain a named patch capture. patch_width controls zero-padding and strip is the non-negative path-component count passed to patch -p (both default to zero).

[source.pypi] #

Selects a project source distribution from PyPI's standardized JSON Simple API. This is the low-maintenance choice for recipes that should automatically discover new PyPI releases:

[source.pypi]
project = "asciidoc"
artifact = "sdist"
versions = ">= 10.2.1"

All three fields are declarative rather than URL templates. project is the PyPI project name; artifact is required and currently must be exactly "sdist"; and versions is the usual optional source version cap. Pekit normalizes the project name for the /simple/<project>/ request, asks for the versioned JSON representation, and considers only standardized .tar.gz sdists.

Automatic discovery deliberately excludes yanked files, prereleases, wheels, legacy non-standard sdist filenames, and versions outside Pekit's version grammar. A release must expose exactly one eligible sdist with a valid SHA-256: none is pypi_sdist_missing when requested exactly, while multiple candidates are pypi_sdist_ambiguous. Pekit never guesses between files.

The selected file's index-advertised SHA-256 is verified on download, and its exact file URL and digest are written through the ordinary pekit.lock mechanism. Once locked, an exact-version rebuild does not need the live project index: it replays the pinned URL and hash from the lock (using the artifact cache when present). --latest, constraints, and --all-versions still consult the index so they can discover newly published versions. A release that is yanked after it was locked therefore remains rebuildable by exact version but is not selected during a fresh automatic discovery.

[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.

Additional inputs #

A recipe has one source. When a build genuinely consumes a second upstream, declare it as an input: [input.<name>]. The Peios kernel is the case this exists for — it is built from the pkm tree and the pristine Linux tarball, and neither is the other's patch series.

An input is a [source.url] addressed by a name. It takes the same fields, including a signature block, and pekit puts it through the same download, the same signature verification and the same lockfile pinning.

[input.linux]
url         = "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{{version}}.tar.xz"
listing_url = "https://cdn.kernel.org/pub/linux/kernel/v7.x/"
file_regex  = 'linux-[0-9]+\.[0-9]+(?:\.[0-9]+)?\.tar\.xz'
versions    = "= 7.0.9"
extract     = true
root        = "linux-{{version}}"

[input.linux.signature]
url          = "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{{version}}.tar.sign"
of           = "decompressed"
key_files    = ["keys/greg-kroah-hartman.asc"]
fingerprints = ["647F28654894E3BD457199BE38DBBDC86092693E"]

Three things differ from a source:

  • An input carries its own version. {{version}} inside an input block is that input's version, never the recipe's. The two move independently.
  • An input is pinned, not discovered. Its versions must name exactly one version — "= 7.0.9", or a bare "7.0.9". A range is rejected (invalid_versions), because an input is never swept by --latest or --all-versions. Moving one is a deliberate edit.
  • An input takes no patch series. Patches apply to the source tree.

Targets read the materialised input at $PEKIT_INPUT_<NAME>, upper-cased with hyphens becoming underscores, so [input.linux] is $PEKIT_INPUT_LINUX. With extract = true it is the extracted tree, with root naming the directory to promote; otherwise it is a directory holding the downloaded file. Under isolation each input is bind-mounted read-only, so a target reads exactly the verified bytes and cannot alter them.

[build.upstream]
command = 'cp -a "$PEKIT_INPUT_LINUX/." "$PEKIT_OUT/"'

Because pekit fetches and verifies inputs itself, before any build root exists, a build needs no network access and no PGP implementation of its own — which is what lets the same recipe work identically on a native rung and on a Debian reference rung, where only Debian packages are installed.

Each input's pristine archive is carried in the recipe's source package under upstream/<name>/, and its hash is in the lockfile, so a recipient can verify those bytes independently.

Inputs in a delegated source #

A source tree that carries its own pekit.toml can declare inputs there, next to the targets that read them. When a recipe delegates its build to that tree (delegate = true, or build = true in a [delegate] table), the source's inputs come with the borrowed targets. The Peios kernel works this way: pkm declares [input.linux] with kernel.org's signing key committed beside it, so the Linux release it is derived from moves in the same commit as its patch series, and the catalogue recipe that builds it stays a pointer.

  • The delegating recipe does the fetching and pinning. Pins go in its own pekit.lock, the one record of everything the build consumed. The source tree is never written to.
  • Keys resolve where they were declared. A relative key_files path names a file beside the source's pekit.toml, and lint reports the source's inputs against that file.
  • A recipe input overrides by name. If the delegating recipe declares an input with the same name, it replaces the source's input as a whole, the same rule that governs delegated targets.

Because each release of the source can name its own input version, the lock keys input pins by name and version. Building an older release verifies against the pin that release recorded.

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.

Tracked-path git (git). Pekit keeps a separate bare cache under <out_dir>/_source_cache/git-tracked/<hash>/repo.git. Discovery shallow-fetches the fixed ref with a blob:none partial-clone filter, resolves its commit, and requests only tracked_path's blob. A server without partial-clone filtering may transfer the other blobs in that shallow snapshot, but Pekit still materialises and packages only the selected regular file. A locked resolve uses the lock's commit directly and never consults the moving ref; a cold cache therefore requires a server that permits fetching that pinned commit object. The materialised tree is recreated from the verified blob on every resolve, then any recipe patch series is applied. Provenance is git:<url>@<commit>:<path>#sha256:<blob-sha256>.

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. For a remote patch series, download patches 1 through the selected patchlevel. Verify the base and every patch against the version's lockfile entry, cache hits included — or atomically create the entry, verifying all configured upstream signatures first.
  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 remote patches in numeric order with patch, no fuzz, then 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 materialised tree is reused, including for checksum-pinned sources, while its manifest's source identity matches: source kind, rendered URL, checksum, extraction settings, root, provenance and patches. Checksum and lock verification still run on every resolve. An identity change invalidates the materialised scope and its staged outputs.

The source timestamp is metadata, not part of that identity. Tar downloads use their newest member's timestamp; anchored downloads without an archive date fall back to the recipe directory's last commit time. A commit changing that fallback date refreshes source.pekit.json without replacing or retiming source files or discarding completed builds. Thus --no-build can reuse a staged build across a notes or lockfile commit. New target executions receive the current timestamp in their environment; reused build outputs are not rebuilt just to change it. Package provenance checks still apply separately.

PyPI (pypi). Pekit first selects the one eligible sdist for the chosen version as described above, then uses the URL materialisation path: the advertised hash is verified, the pristine .tar.gz is cached, its standardized top-level directory is extracted, patches are applied, and the exact file is locked. Provenance is pypi:<normalized-project>@<version>#sha256:<hash>.

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. Tracked-path git removes its sparse cache and refetches the selected immutable commit (for an exact lock) or moving ref (during discovery), without changing what the lock asserts.
  • url / PyPI: 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 or PyPI source, every ordered remote-patch URL and SHA-256 when present, or the resolved commit for an ordinary git source, with the verified signer's fingerprint when the recipe has a [source.git.signature]. A tracked-path git entry records the repository, fixed ref, path, commit, blob object ID, and blob SHA-256. 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 for ordinary git and URL sources:

pekit lock --repin --version 1.2.0

Additional inputs are pinned in the same file, in their own [[input]] entries keyed by name and version:

[[input]]
  name = "linux"
  version = "7.0.9"
  url = "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.0.9.tar.xz"
  sha256 = "ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4"
  signature_key = "647f28654894e3bd457199be38dbbdc86092693e"

An input asserts the same thing a url source does — these bytes, verified by this key — and stops the same way when they change. Moving an input to a new version is an edit to its versions constraint. The new version is verified and pinned the first time it resolves, like a new version of a source, and the old pin stays.

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.

Corresponding-source bundles preserve relative dangling symlinks used by upstream test fixtures, provided every component remains inside the exported tree. Both publication and rebuild.py reject absolute targets, escaping traversal, cycles and non-directory parents. A missing link target is not a missing declared input: all declared files and the original link text still undergo identity checks. Compact prepared-source archives apply the same checks within source/.

Archive extraction #

For an extracted URL source, exclude optionally names files or directory subtrees to omit, relative to the selected root:

[source.url]
url = "https://download.qemu.org/qemu-{{version}}.tar.xz"
extract = true
root = "qemu-{{version}}"
exclude = ["roms/edk2"]

Each entry is an exact, canonical relative path, without a trailing slash, .. components, backslashes, glob patterns or templates. roms/edk2 omits that entry and its descendants, but keeps roms/edk2-other. The root itself cannot be excluded, and a nonempty list requires extract = true. A path absent from the archive is harmless. The same option is available for [input.<name>] archives.

Exclusions are applied before creating archive entries: an omitted subtree may contain an escaping symlink, but the symlink is never created. Every member name still undergoes the extraction-root containment check, including omitted members. All normal safety checks continue to apply to retained entries. Exclusion does not dereference links or permit otherwise unsafe retained entries.

The exclusion list participates in the extracted-source and input cache identity; changing it cannot reuse a tree extracted with a different list. Downloads, checksum/signature verification and lockfile hashes still cover the complete upstream artifact. Corresponding-source packages retain that pristine artifact, including excluded paths. Recipe patches apply to the filtered source tree through the usual [source] patches mechanism.

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 and PyPI 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.

This recipe-owned series is distinct from [source.url.patch_series], whose patches are upstream inputs: remote patches are signature-verified and locked, ship below upstream/patches/, and are applied before any recipe-owned patches.

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. A named version capture supplies the complete version. Named major, minor, and patch captures instead compose a dotted version. Optional revision, suffix, prerelease, and buildmeta captures are appended: revision becomes a fourth numeric component, while suffix is unseparated. For example, ref = "{{major}}{{minor}}{{patch}}" with tag_regex = '^(?P<major>\d{4})(?P<minor>\d{2})(?P<patch>\d{2})$' maps the tag 20260810 to version 2026.08.10 and renders it back to the same ref. A ref ending {{suffix}}{{revision_suffix}} can likewise round-trip either microcode-20230516a as 2023.05.16a or microcode-20260210-rev1 as 2026.02.10.1. Without named version captures, pekit extracts the version from the ref template or an embedded MAJOR.MINOR.PATCH; unnamed capture groups only filter tags.
  • tracked-path git: shallow-fetch the fixed ref and expose matching versions already in pekit.lock, plus one newly synthesized date version only when the current tracked blob differs from the newest lock.
  • url: fetch listing_url when set, otherwise derive a directory listing from the url template (the part before the first {{…}}, up to the last /); filter entries by file_regex, and extract versions from the matches. Named captures have the same mapping semantics as Git tag_regex: for example, (?P<major>\d{4})(?P<minor>\d{2})(?P<patch>\d{2}) exposes 20260810 as 2026.08.10, which a URL containing {{major}}{{minor}}{{patch}} renders back to the upstream filename. Without named version captures, Pekit extracts an embedded version as before.
  • PyPI: fetch the project's standardized JSON Simple API page once per invocation and enumerate the eligible sdists described under [source.pypi].

Local and sourceless recipes cannot enumerate (version_enumeration_unavailable) — they require an exact version. The versions field on a git, url, or PyPI 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>
tracked-path gitgit:<url>@<commit>:<path>#sha256:<blob-sha256>
url (checksummed)url:<url>#<checksum>
url (locked, no checksum)url:<url>#sha256:<hash>
url with remote patchesthe locked url ref above plus +patches:sha256:<series-hash>
url (no checksum, no lock entry)url:<url> (marked unanchored — reachable only in a dry run, since a real resolve locks)
PyPIpypi:<normalized-project>@<version>#sha256:<hash>

For git, url, and PyPI 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.

Plain pekit lint acquires one delegated source snapshot and statically checks these merged recipe and package definitions without building. An explicit version or source selector additionally enables payload rules against that selection's existing build stage.

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:

NUMERIC_COMPONENT[.NUMERIC_COMPONENT...][SUFFIX][-PRERELEASE][+BUILDMETA]

The numeric core contains one or more dot-separated decimal components. An optional unseparated SUFFIX starts with an ASCII letter and continues with ASCII letters or digits; this covers upstream schemes such as IANA tzdata's 2026c. The optional -PRERELEASE and +BUILDMETA tails may each contain digits, ASCII letters, dots, and hyphens.

Written versionmajorminorpatchcomplete numeric coresuffixprereleasebuildmeta
222
2026c20262026c
2.432432.43
2.43.124312.43.1
0.5.13.505130.5.13.5
1.21.0-rc.112101.21.0rc.1
1.21.0+build.512101.21.0build.5
1.21.0-rc.1+bld12101.21.0rc.1bld

Anything that does not match this grammar is rejected with an invalid_version error. Comparison and ordering use every numeric component, without a machine-integer size limit. A missing component counts as 0, so 1.2, 1.2.0, and 1.2.0.0 have equal numeric cores. Suffixes sort after the matching bare numeric core and lexically among themselves (2026 < 2026a < 2026b); the prerelease string then breaks ties. Build metadata does not affect ordering.

Version template variables #

Nine 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 nine 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 first numeric component
{{minor}}the second numeric component
{{patch}}the third numeric component
{{revision}}the fourth numeric component, or empty
{{revision_suffix}}-rev plus the fourth component, or empty
{{suffix}}the unseparated suffix, or empty
{{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}}"

Three rules govern rendering:

  • The fourth numeric component is also available as a corrective revision. The first three component variables retain their compatibility meanings. For example, 2026.02.10.1 renders unchanged through {{version}}, exposes 1 through {{revision}}, and renders -rev1 through {{revision_suffix}}. Fifth and later components remain available only through {{version}}.

  • 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). {{suffix}}, {{prerelease}}, and {{buildmeta}} are the exception — they render as empty text when absent rather than erroring. {{revision}} and {{revision_suffix}} behave the same way.

  • Unknown variables are an error. Any {{name}} that is not one of the nine 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_SUFFIX, 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: an ordinary [source.git] source (via git ls-remote --tags), a tracked-path git source (from matching lock history plus a changed blob at its fixed ref), a [source.url] source (by fetching the listing directory), or a [source.pypi] source (from the project's JSON Simple API page). --latest, --all-versions, and any constraint require one; against a non-enumerable source they fail with selected source cannot enumerate versions.

A URL source with one exact versions value (for example, "= 1.83.0"), no listing_url, and no patch series has a singleton version set. Automatic selectors use that declared version without requesting a directory listing. This supports pinned bootstrap inputs on hosts that serve artifacts but do not expose indexes. Artifact download, lock checks, and signature verification still apply. A range such as ">= 1.83.0" still requires discovery, and an explicit listing_url is always consulted.

Selecting from the lock #

Discovery runs every time: --latest on Monday and --latest on Tuesday can build different versions if upstream released in between. For a run that must stay on one version set, such as a workspace rebuilt in several rounds, lock the versions once and add --locked:

pekit workspace lock --latest
pekit workspace publish --all --latest --locked

With --locked, --latest, --all-versions and constraints draw their candidates from the [[source]] entries in the recipe's pekit.lock instead of upstream. The source's own versions cap still applies. A recipe with no locked versions fails with version_selection_empty; a recipe with no source runs once, as it does without the flag. --locked only chooses versions: fetching the chosen source and checking it against the lock work as usual.

--locked is refused on its own, with an exact --version (which does not enumerate anyway), and on lock, whose job is to discover.

--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
2.43.7.0.02.43.7.0.0, then 2.43.7.0, then 2.43.7

Only trailing zero components are dropped — a non-zero component is never elided. The ladder works across any number of numeric components. It 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.

PyPI and tracked-path git exact selectors are the exceptions: they are used literally and do not consult the live index or moving ref for ladder matching. This lets a PyPI version rebuild from its pinned URL and SHA-256, and a tracked git version rebuild from its pinned commit/path/blob, when upstream is unavailable. Their automatic selectors already expose exact canonical version spellings.

Tracked-path git synthesizes numeric versions from the UTC discovery date: YYYY.MM.DD, then .2, .3, and so on for further changed blobs locked on the same date. Unchanged bytes create no version. --all-versions combines every matching historical lock with the current changed blob rather than treating the branch tip as the whole history.

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]
"org.openssl.libssl" = ">= 3.0"
"libc.so.6"          = "*"
"pkgconfig(zlib)"    = ">= 1.2.11"

[optional_dependencies]
"org.gnu.bash-completion" = "*"

Each entry maps a name to a version constraint string. A concrete package is named by its canonical reverse-DNS identity; unqualified package aliases are not a supported compatibility layer. A virtual capability instead names an interchangeable interface, such as a soname (libc.so.6), a pkgconfig(...) token, or an explicitly documented role such as sh. 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
"io.github.zlib-ng.zlib-ng-compat" = ">= 1.2"

# table form: constraint + explicit root
[dependencies."org.example.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.

Retaining an older library ABI #

A runtime compatibility package contains the actual maintained older libraries, with their original SONAMEs. It does not alias a new ABI to an old filename or claim to replace a development interface. The resolver can install both versions when the required library filenames differ.

For OpenSSL, org.openssl.openssl tracks current upstream and supplies the command and development interface through its normal split packages. org.openssl.openssl3 follows authenticated 3.5 LTS patch releases and supplies libcrypto.so.3, libssl.so.3, the OpenSSL 3 engines, and its legacy provider. It has no command, headers, static libraries, or unversioned linker symlinks. The current OpenSSL 4 command uses openssl rehash; upstream removed the separate c_rehash command. This runtime package does not restore that command. ELF dependencies on the .so.3 names select it automatically. A package that explicitly pins an obsolete concrete OpenSSL package revision still needs its metadata updated; ABI compatibility does not override version constraints.

OpenSSL 3 reads /etc/ssl/openssl3.cnf and /etc/ssl/ct_log_list-openssl3.cnf; package defaults are installed under /usr/etc/ssl. Its provider directory is /usr/lib/x86_64-linux-peios/ossl-modules-3. OpenSSL 4 retains the canonical configuration and provider paths. Both use /etc/ssl/cert.pem and /etc/ssl/certs for the system trust store. Administrators with custom OpenSSL configuration should apply the settings required by older consumers to the OpenSSL 3 configuration too; explicit OPENSSL_CONF and OPENSSL_MODULES overrides remain application-specific and must match the selected ABI.

The compatibility recipe retains the 3.5 LTS maintenance line, supported upstream through April 2030 (upstream support dates). Its discovery bound does not freeze patch releases; the shorter-lived 3.6 line is deliberately excluded. Remove the compatibility package only when supported consumers no longer require that ABI.

Foreign static Linux targets #

org.libc.musl-sysroot supplies a private static sysroot for Rust's x86_64-unknown-linux-musl cross target. Its headers, static archives and startup objects live under /usr/lib/x86_64-linux-peios/musl. It installs no dynamic loader, shared process libc or command, and provides no generic libc capability. Peios applications continue to use the native glibc toolchain.

This target follows upstream musl's Linux ABI. It does not promise Peios-native CET shadow-stack support: upstream assembly, including nonlocal jumps, does not implement that contract. Do not force ELF property notes to advertise it. The native compiler and native application packages retain their normal CET gates.

The mandatory sysroot gate compiles and runs a self-contained static PIE against the staged headers and archives. It exercises thread creation/join, setjmp / longjmp, signal-mask restoration with sigsetjmp / siglongjmp, and the patched GB18030 decoder. It also checks non-executable stack, RELRO, eager relocation, compressed relative relocations and absence of a dynamic loader or shared-library dependencies. These are cross-target functional and layout checks, not a claim that foreign binaries satisfy every Peios-native execution policy.

Symbol version policy #

By default a soname dependency is derived at soname granularity only: the name must match exactly, with no version constraint. That alone cannot establish symbol-version compatibility: 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. It accepts symbol_versions and symbol_capabilities; unknown keys are errors. Recipes and package files cannot set it. When no workspace policy is configured, every soname is derived at name granularity.

Exact ELF version capabilities #

Libraries such as libstdc++ number their ABI versions independently of their package releases. Select these SONAMEs explicitly:

[policy]
symbol_capabilities = ["libstdc++.so.6"]

Pekit derives elfver(libstdc++.so.6:GLIBCXX_3.4.30) and corresponding CXXABI capabilities from the actual GNU ELF version tables. Providers advertise each non-base version definition; consumers require each strong version need in addition to the SONAME. These names carry no package-version constraint. The library name scopes the token, so a definition from another library cannot satisfy it. Weak version needs remain optional. Exact definitions are compared, not an assumed numeric maximum or a hand-maintained GCC release mapping.

Symlinks and source/debug-source fixtures are excluded. A package's own library satisfies only the exact capabilities it defines. An incompatible bundled library, multiple objects supplying the selected SONAME, malformed version tables or unrepresentable names fail package creation. Such errors must not silently fall back to SONAME-only requirements.

For rollout, publish updated runtime providers before publishing consumers with these requirements. Existing binaries and manifests are not retroactively qualified. Consumers originally linked against an unversioned runtime need relinking against a versioned runtime; packaging cannot reconstruct symbol versions absent from the binary. An old provider with only a SONAME capability cannot satisfy a new versioned requirement. Normal resolver checks also reject runtime upgrades that would leave installed consumers' requirements unsatisfied. This does not pin an exact GCC release or guarantee compatibility across arbitrary C++ ABI-changing compiler flags; normal upstream ABI gates still apply.

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.

Debian reference dependencies without a Debian package #

The e2fsprogs security-descriptor extensions depend on libpeios, which Debian cannot install from APT. Its recipe therefore declares a private reference SDK prerequisite. The input is the unchanged signed dev.peios.libpeios-source 0.5.0-1 publication, with its repository key, MIT licence, source commit and verified archive hashes recorded beside the recipe. Acquisition refuses changed bytes before extracting the source.

That older source publication did not contain a vendored dependency closure. The acquisition target runs its original lockfile-bound Cargo vendor command and retains the PKM UAPI from the same fixed Git revision. These generated inputs are captured in the e2fsprogs source bundle. The offline SDK target builds from them and runs the frozen source's tests, ABI/export checks and dynamic/static consumer probes. The main reference build receives the SDK as an explicit prerequisite.

The snapshot establishes the reference ABI0 dependency; it needs review when required APIs change or prerequisite security maintenance requires a refresh. It does not pin native or runtime libpeios packages and does not modify or republish the active first-party source tree.

Qualification evidence (2026-09-16): both providers passed the required build, test and archive lint gates. Each ran the upstream 19-test internal suite and 390-test filesystem suite with zero failures; 17 upstream skips remain (slow, privileged, unavailable optional features and platform exclusions). The Debian prerequisite ran 256 Rust tests and the frozen ABI consumer/export checks. Exact cbindgen 0.29.2 was unavailable, so the frozen prerequisite policy verified the committed ABI snapshot instead of regenerating it; it checked standalone C/C++ headers and 207 DSO exports.

All 22 resulting archives passed independent trusted-key signature and payload hash verification. Both source bundles reconstructed 3,656 authenticated input identities and the expected build dispatch. This checks source reconstruction, not an additional rebuild. Candidate installation is still blocked by the existing repository closure's missing libblkid.so.1; recipe qualification is not a claim of image or public-release acceptance.

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, selected workspace env file, recipe, and selected recipe 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 these places, each contributing to the user layer:

  • the workspace file (applies to every member),
  • the selected env file at the workspace root (applies to every member),
  • the recipe (pekit.toml),
  • the selected env file beside the recipe (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], then the selected workspace env file
  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 recipe env file's [env]

So a variable set in the recipe overrides the same variable inherited from the workspace profile, and the selected recipe 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, using double quotes that preserve spaces. This 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 at the workspace root or beside a recipe. It carries an [env] block, a [wrap] wrapper, a dependency_provider, a [sandbox] root preparer, a [lint] table, or any combination of them. It lets you keep environment- or profile-specific settings out of the recipe proper. An env file must declare at least one of env, wrap, dependency_provider and sandbox (see Isolated production jobs). The only other recognised top-level key is lint, which adds rule exceptions that belong to the environment rather than to any package.

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. Any other name selects a profile such as release.env.pekit.toml (--env release). In a workspace, pekit loads that file from the workspace root and then from the recipe directory; either may be absent, but a named profile must exist in at least one location. A recipe-local file overlays the workspace profile. Outside a workspace, the recipe-local file retains the same semantics. If both paths resolve to the same underlying file (for example through a legacy member symlink), pekit applies it only once. Passing --env none disables both layers.

# 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. selected workspace env file [wrap]
  3. delegated source env file [wrap] (only when the recipe delegates wrap)
  4. recipe [wrap]
  5. selected recipe env file [wrap]

So a recipe wrapper overrides the workspace profile's, and the selected recipe 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. The precedence is the same as for wrappers: selected workspace profile, delegated source env file, then selected recipe env file. The last non-empty value wins. See Dependencies and claims for what the selection exports.

Keyrings #

Keyrings supply keys to the coordinator. Targets receive no keyring entries by default. A target requests specific public or acquisition inputs with keyring_inputs, and the operator's keyring must separately grant the matching access. Private signing keys remain coordinator-only. Keyring files and inline literals continue to resolve through the same precedence rules.

--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>
# coordinator-only; never exported to the target

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 #

The well-known package-signing entry is read by Pekit itself: 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.

A [publish.peipkg] target may similarly select any keyring leaf with a keyring:<dotted.entry> signing_key value. The leaf contains the path to the Ed25519 private key used to sign repository metadata; the conventional name is signing.repository_key. This is target-selected rather than a second well-known entry because different publish destinations may use different repository keys.

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 #

Legacy string leaves remain supported as coordinator-only values. To expose a value to a worker, use a typed entry with value and access:

# prod.keyring.pekit.toml — operator-controlled and gitignored
[tcb]
priv = "/secure/tcb.pem"
pub = { value = "PUBLIC_KEY_HEX", access = "public" }

[sources]
registry_token = { value = "TOKEN", access = "acquisition" }
# pekit.toml
[build.main]
keyring_inputs = ["tcb.pub"]
command = 'generate-header "$PEKIT_KEYRING_TCB_PUB"'

[build.vendor]
keyring_inputs = ["sources.registry_token"]
command = 'fetch-sources'

public inputs are available to requesting targets. acquisition inputs are restricted to build.vendor. signing entries and legacy strings cannot be requested by workers. An inline override resets the entry to coordinator-only, so it cannot accidentally inherit a public grant from an earlier keyring file. Values are literal strings, including public certificate PEM content; a path value does not grant filesystem access. Typed path and content entries remain unsupported. Colliding normalized entry names are rejected.

A note on secrets in output #

Explicitly granted worker inputs are exported like other environment variables. 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.

Isolated production jobs #

A workspace can require Pekit-controlled isolation:

# workspace.pekit.toml
[isolation]
enabled = true
inputs = ["_pybuild_"]

The selected workspace environment profile prepares a dependency root. Put the profile most jobs use in the workspace's env.pekit.toml, so commands run isolated without --env, and keep named profiles such as debian.env.pekit.toml for the exceptions:

# env.pekit.toml
dependency_provider = "peipkg"
[sandbox]
command = 'exec "$PEKIT_WORKSPACE_ROOT/_peiroot_/enter.sh"'
network_targets = ["build:vendor"]
entry = ["/usr/libexec/peiroot/entry"]

The preparer receives PEKIT_SANDBOX_ROOT, a fresh destination path, and PEKIT_JOB_STATE, coordinator-only job storage. It installs declared dependencies and exits. It receives managed variables, not recipe environment expansions or keyring values. Pekit then runs the target through Bubblewrap with a private PID, mount and user namespace, a cleared environment, and no network by default. A profile may set network_targets = ["build:vendor"]; this shares the host network for acquisition, including its DNS configuration, without mounting the host root or home. Other target names cannot enable network access. The worker receives its dependency manifest through a read-only file at PEKIT_DEPENDENCIES_FILE.

A profile may also name an entry program, which the preparer places in the root. Pekit runs it inside the sandbox ahead of each target command and it execs the command, so a profile can start job-scoped services that the isolated root needs; anything it leaves running ends with the job. The Peios default profile uses this for name resolution during acquisition. Peios glibc resolves hosts only through resolvd's socket, which no sandbox runs, so a native acquisition root gets resolvd's NSS module and a small build-root resolver that answers on that socket from the copied resolv.conf.

That profile acquires in a native root built from the vendor target's peipkg dependency set, adding only a shell, Python for the resolver, the NSS module and TLS trust; recipe constraints take precedence. A vendor target with an empty peipkg set is rejected.

Delegated and member [env] layers still apply inside the worker. Their wrappers cannot replace workspace isolation or its dependency provider. --env none or a profile without a root preparer fails before target outputs are cleared. Sandbox profiles outside an isolation-enabled workspace are rejected rather than run unprotected.

Authenticated source caches remain outside the worker. The source receives a writable copy by default, at the same visible path: no allow_write toggle is needed. Job outputs retain their paths across stages, and completed stages in the same job may be modified by later stages and tests. Sibling recipes, repository indexes, signing keys, host home and completion records are not mounted. Shared workspace inputs must be named explicitly and are copied into read-only mounts. Copies exclude Git databases, keyrings, configured key files and common local credential files; source trees must still contain only intended build inputs.

Pekit serializes invocations of the same recipe. A worker's descendants are gone before signing starts. PIP and module signatures are applied by the coordinator; package/publish reapplies signatures after gates that may have rebuilt outputs. Signing paths that escape the stage through symlinks are rejected.

--no-build retains both staged outputs and the private source copy. Outputs created before isolation was enabled must first be rebuilt; their old stages may contain leaked signing material and are not exposed to new workers. An explicit gen runs against a private copy and applies its source changes through a bounded filesystem root after successful completion. Concurrent source edits cause it to abort. verify never writes the source checkout back.

The Peios root preparer snapshots signed repository metadata once per job and records each resolved dependency closure. Debian preparation records installed package versions and its base image identity. This is automatic build provenance: recipes can continue selecting current upstream releases and wildcard dependency versions. A new job selects current inputs again; no hand-maintained version pins are required.

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.