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

pekit

The recipe-driven build and packaging tool — declarative recipes in; built, tested, packaged software out.

Single-page view · as markdown

What is pekit

pekit / Getting started

Pekit is the tool that turns source into packages. You describe a piece of software once — where its source comes from, how to build it, and how to split the result into .peipkg files — in a set of declarative recipe files, and pekit does the rest: fetches the source, selects versions, runs the build, stages the output, and writes packages you can install or publish.

Everything in the Peios tree is built with pekit. The kernel, the registry store, the init system, the package manager itself — each is a recipe, and pekit package produces the .peipkg files that peipkg-compose and peiso assemble into a running system.

The one idea: plan before execute #

Pekit's guiding principle is predictability. Every command declares exactly which flags it accepts, every source declares what it can do, and every build is planned before any side effect happens. Before pekit clones a repository, runs a shell command, or writes a package, it has already resolved which recipe is selected, which source tree is needed, which versions apply, which build targets will run, and which artifacts will be produced.

That plan is inspectable. Add --dry-run to any command and pekit builds the same plan it would execute, prints it, and stops — no clones, no downloads, no writes. Strictness is the other half of the same idea: unknown flags, unknown recipe keys, and flags a command does not support are errors, caught up front, not halfway through a build.

The pipeline #

A single invocation runs through a fixed sequence:

argv
  → invocation        parse and validate flags against the command
  → recipe            locate and load the recipe (walking up from the cwd)
  → versions          resolve the version selector into concrete versions
  → source            materialise the source tree for each version
  → delegation        merge any behaviour borrowed from the source tree
  → execute           run targets, or stage builds and write packages
  → artifacts         staged output and .peipkg files under out_dir

build, package, and publish may run this for several versions at once; test and install require the selector to resolve to exactly one. clean, gen, and verify skip the version and source stages entirely — they operate on the recipe directly.

The shape of a recipe #

A recipe is a directory. At minimum it holds one file:

mypkg/
├── pekit.toml                 # the recipe: source, build/test/install/clean targets, env
├── package.pekit.toml         # what to package: files, symlinks, dependencies, metadata
├── packages.pekit/            # (optional) extra package members for multi-package recipes
├── env.pekit.toml             # (optional) reusable environment / wrapper definitions
└── prod.keyring.pekit.toml    # (optional) named keyring for signing and secrets

pekit.toml is the recipe proper — it declares the source, the build/test/install/clean targets, and shared environment. The package files describe how staged build output is mapped into one or more .peipkg payloads. A workspace.pekit.toml one level up turns a directory of recipes into a workspace that pekit can drive as a unit.

The Anatomy of a recipe page walks through every file; the recipe format reference is the exhaustive schema.

The commands #

Ten commands, each with a defined job:

CommandWhat it does
buildRun build targets and their build dependencies.
testStage the builds a test needs, then run test targets.
installStage the builds an install needs, then run install targets.
packageStage builds and write .peipkg package artifacts.
publishPackage, then publish the artifacts to a configured destination.
cleanRun a clean target and/or remove pekit's managed output directory.
genRun source-generating targets that write generated source into the tree.
verifyRun gen targets' drift checks without writing anything.
lockFetch and pin source inputs in pekit.lock without building.
workspaceRun any of the above across every member of a workspace.

The Commands and targets page covers what each command selects, its version behaviour, and how target selection works.

Where pekit fits #

Pekit is the producer-side build tool. It stops at the .peipkg file. What happens next is other tools' work:

  • peipkg installs .peipkg files onto a live system.
  • peipkg-compose assembles a set of packages into an offline package root.
  • peiso turns a composed root into a bootable image.

Pekit produces packages in the peipkg format and understands peipkg concepts directly: recipes declare package dependencies, automatic ELF dependencies are scanned from built binaries, and packages can declare the shared-name claims they participate in. See Dependencies and claims.

Pekit also carries the supply-chain half of producing packages: fetched sources are pinned trust-on-first-use in a lockfile, upstream release signatures can be verified against committed keys, artifacts are Ed25519-signed, binaries can be PIP-signed for the kernel, and every manifest records the provenance of its inputs, recipe, and builder. See Signing and provenance.

Important

Pekit supersedes the old peipkg-build recipe system. Earlier Peios used a peipkg.toml + build.sh recipe built by a separate peipkg-build binary. Pekit replaces that entirely; a pekit recipe (pekit.toml + package files) is the current and only supported way to build a package. Documentation describing peipkg-build and its build.sh recipes is legacy.

What to know before you start #

A few facts about the tool as it ships today:

  • No --help yet. Pekit does not currently print usage text. This page and the invocation reference are the manual; the complete flag surface is tabulated in the command-line reference.
  • Exit status is 0 or 1. Pekit exits 0 on success and 1 on any error. It does not use finer exit codes.
  • Events go to stdout. Pekit's own events print as timestamped lines on stdout (structured JSON with --json); a target's output streams through on its original stdout and stderr, and fatal errors go to stderr.

Where to start #

To learn the recipe format, begin with Anatomy of a recipe and follow the recipes section in order.

To use pekit against an existing recipe, read Commands and targets and Invocation and flags.

For the exhaustive schema and CLI surface, the reference section has one page per file format plus the full command-line reference.

Quick start

pekit / Getting started

This page takes you from an empty directory to a working .peipkg in about five minutes. You will write a two-file recipe, run pekit build, inspect the staged output, then run pekit package and look inside the artifact it writes. At the end you have a complete, minimal recipe you can grow into a real one.

Prerequisites #

  • A pekit binary on your PATH. Pekit is a single binary built from its source repository with go build ./cmd/pekit; there is no separate installer. Note that pekit has no --help or version banner — the docs are the manual.
  • A POSIX shell (sh). Target commands run under sh -euc; any Linux system has this.
  • zstd and tar, for the final inspection step only.

Create the recipe #

A recipe is a directory, and only one file in it is required: pekit.toml. Make a directory and write the recipe:

$ mkdir hello && cd hello
# hello/pekit.toml
out_dir = "out"

[build]
command = '''
mkdir -p "$PEKIT_OUT/bin"
printf '#!/bin/sh\necho "Hello from Peios"\n' > "$PEKIT_OUT/bin/hello"
chmod +x "$PEKIT_OUT/bin/hello"
'''

Two things are going on here:

  • out_dir names the directory pekit manages, relative to the recipe. Everything pekit stages or writes lands under it, and pekit clean may remove it wholesale — keep nothing precious there.
  • [build] in this bare form defines a single build target named main. Its command is an ordinary shell script; pekit runs it with a set of PEKIT_* environment variables exported, of which the important one is $PEKIT_OUT — the target's own staging directory. A build's job is to put its artifacts in $PEKIT_OUT. Here the "build" just writes a shell script; a real recipe would run make, cargo build, or similar and copy the results in.

This recipe has no [source] section, so it builds against its own directory. Fetching real source trees is covered in Sources.

Run the build #

From hello/ (or any directory beneath it — pekit walks upward to find the nearest pekit.toml):

$ pekit build

You should see three timestamped lines, ending in success:

[14:30:01] [build] loaded recipe
[14:30:01] [main] running target
[14:30:01] [main] target succeeded

Nothing else prints because the build command itself is silent; anything your build writes to stdout or stderr is streamed through with a [build:main] prefix. Pekit exits 0 on success and 1 on any error.

Inspect the staged output #

Each build target stages into out/build/<target>/. Look at what the run produced:

$ find out -type f
out/.pekit/dependencies/build.main.json
out/build/main/bin/hello

out/build/main/ is target main's $PEKIT_OUT, and it now holds exactly what the command wrote (the out/.pekit/ entry is pekit's own bookkeeping). This staged tree is what packaging draws from. The script even runs:

$ out/build/main/bin/hello
Hello from Peios

Describe the package #

Packaging is declared in a second file beside the recipe. Write hello/package.pekit.toml:

# hello/package.pekit.toml
format = "peipkg"

[package]
name = "hello"
version = "0.1.0-1"
architecture = "noarch"
description = "A friendly greeting."
license = "MIT"

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

The peipkg format requires [package].version, [package].architecture, and [package].license; the rest of the metadata is optional. The [files] table maps sources to destinations: the left side ":bin/hello" is a build ref meaning "bin/hello from build target main's staged output", and the right side is where the file lands inside the package. Destinations must sit under the peipkg layout's permitted top levels (usr/bin/, usr/share/, etc/, and so on) — see Packaging files.

Write the package #

$ pekit package

package stages the builds the package needs (re-running build.main), then writes the artifact:

[14:31:12] [package] loaded recipe
[14:31:12] [main] running target
[14:31:12] [main] target succeeded
[14:31:12] [main] wrote package: /home/you/hello/out/package/main-e33807f0ae9abdc7/hello_0.1.0-1_noarch.peipkg

The artifact is named <name>_<version>_<architecture>.peipkg and lands in a per-package stage directory under out_dir (the hash in the directory name is derived from the package's identity). Add --dry-run to any command to see this plan without writing anything.

Look inside #

A .peipkg is a Zstandard-compressed tar archive: reserved .peipkg/ metadata entries followed by the payload as it will be installed.

$ zstd -d --stdout out/package/main-*/hello_0.1.0-1_noarch.peipkg | tar tf -
.peipkg/manifest.json
.peipkg/files.json
usr/
usr/bin/
usr/bin/hello

That file is a real package: peipkg can install it, and pekit publish can copy it to a configured destination. To start over, pekit clean removes the whole out/ directory.

Where to go next #

  • Anatomy of a recipe — every file in the recipe directory, all of pekit.toml's sections, and the full PEKIT_* environment contract.
  • Commands and targets — the commands, named targets, needs ordering, target selection, and the gen/verify source-generation pair.
  • Invocation and flags — the command line: --dry-run, --json, and how recipes are located.
  • Recipe format reference — the exhaustive schema for pekit.toml and package files.

Anatomy of a recipe

pekit / Writing recipes

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

The recipe directory #

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

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

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

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

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

pekit.toml #

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

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

The one pekit itself ships with is deliberately minimal:

out_dir = "out"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

[clean]
command = "cargo clean"

The top-level keys break down as follows:

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

Targets are shell commands #

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

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

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

[build]
command = "make"

…or in named form, one subtable per target:

[build.main]
command = "make"

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

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

The PEKIT_* environment contract #

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

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

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

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

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

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

Shell variables, not templates #

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

How pekit finds the recipe #

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

You can override this:

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

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

Where to go next #

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

Sources

pekit / Writing recipes

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

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

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

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

The three source kinds #

[source.git] #

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

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

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

Note

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

[source.url] #

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

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

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

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

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

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

[source.local] #

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

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

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

Selecting the local override #

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

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

Both accept an inline path:

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

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

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

Materialisation #

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

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

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

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

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

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

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

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

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

Caching and --refresh-source #

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

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

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

The lockfile #

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

pekit lock --repin --version 1.2.0

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

Source packages #

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

Archive extraction #

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

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

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

Patches #

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

[source]
patches = "patches"

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

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

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

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

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

Enumeration #

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

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

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

Source roots and provenance #

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

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

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

Delegation #

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

delegate = true

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

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

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

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

Where to go next #

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

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

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

Versions

pekit / Writing recipes

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

Version forms #

A version is written as:

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

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

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

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

Version template variables #

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

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

Two rules govern rendering:

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

Important

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

Selecting versions #

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

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

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

--version as an exact selector #

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

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

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

--version as a constraint #

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

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

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

--latest and --all-versions #

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

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

The trailing-zero ladder #

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

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

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

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

Source version caps #

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

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

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

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

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

Local builds #

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

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

Single-version commands #

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

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

Where to go next #

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

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

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

Packaging files

pekit / Writing recipes

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

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

The [files] table #

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

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

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

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

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

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

Source refs #

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

Build-output refs — target:path #

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

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

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

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

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

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

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

Plain paths are owner-relative #

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

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

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

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

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

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

Note

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

Path cleaning #

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

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

Globs #

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

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

Rules:

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

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

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

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

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

Excludes #

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

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

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

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

Destination rules #

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

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

Directory vs single-file destinations #

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

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

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

Collisions are errors #

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

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

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

override #

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

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

[symlinks] #

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

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

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

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

The result #

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

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

Where to go next #

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

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

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

Dependencies and claims

pekit / Writing recipes

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

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

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

Declared package dependencies #

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

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

[optional_dependencies]
bash-completion = "*"

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

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

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

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

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

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

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

Derived dependencies: ELF and pkgconfig scanning #

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

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

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

What ELF derivation detects #

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

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

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

Two refinements keep the result accurate:

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

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

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

Symbol version policy #

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

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

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

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

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

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

Build dependencies exported to the build command #

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

[build.main]
command = "make"

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

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

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

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

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

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

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

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

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

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

Claims #

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

Important

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

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

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

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

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

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

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

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

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

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

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

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

Where to go next #

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

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

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

Multi-package recipes

pekit / Writing recipes

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

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

Package files and layers #

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

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

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

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

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

How many packages a recipe emits #

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

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

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

Merge and precedence #

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

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

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

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

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

Selecting packages on the CLI #

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

Selection works like target selection:

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

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

invalid_flags: --all cannot be combined with package selectors

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

Multipack: one definition, many instances #

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

Static enumeration #

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

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

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

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

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

Enumeration from files #

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

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

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

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

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

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

The {{multipack}} variable #

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

{{multipack}} is not available in this context

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

Selecting one instance #

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

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

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

Instance naming and artifacts #

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

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

Dry runs and unresolved instances #

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

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

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

Worked example: runtime plus docs #

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

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

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

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

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

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

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

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

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

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

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

Where to go next #

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

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

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

Environments and keyrings

pekit / Writing recipes

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

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

The three layers #

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

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

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

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

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

The [env] block #

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

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

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

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

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

Composition and override #

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

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

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

Shell expansion of [env] values #

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

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

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

Env files and --env #

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

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

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

[env]
CFLAGS = "-O3 -DNDEBUG"

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

[wrap] wrappers #

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

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

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

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

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

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

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

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

dependency_provider #

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

Keyrings #

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

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

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

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

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

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

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

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

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

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

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

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

Precedence #

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

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

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

Package signing #

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

Binary signing #

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

Keyring file format #

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

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

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

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

A note on secrets in output #

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

Where to go next #

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

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

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

Commands and targets

pekit / Running pekit

Every pekit invocation names exactly one command. The command decides three things up front, before any side effect: which flags are legal, how many versions the run may cover, and which targets in the recipe are selected and in what order. This page is the reference for that surface.

For the flags themselves (how they parse, global flags like --dry-run), see Invocation and flags. For version selectors (--version, --latest, --all-versions), see Versions.

The commands #

CommandSelectsVersions--allSide effects
buildbuild targets + their needsmultiplenoRuns build targets; stages their output under out_dir.
testtest targets + the builds they needsingle resolvednoStages needed builds, then runs the selected test targets.
installinstall targets + the builds they needsingle resolvednoStages needed builds, then runs the selected install targets.
packagepackage members + the builds they listmultipleyesStages builds and writes .peipkg artifacts under out_dir. Recipes with a reproducible source also emit a corresponding-source package.
publishpackage members (as package)multipleyesPackages, then publishes to a configured localdir destination.
cleanone optional clean targetnonenoRuns a clean target and/or removes the managed output directory.
gengen targetsnoneyesRuns gen commands; writes generated source into the tree.
verifygen targetsnoneyesRuns gen verify_commands; a read-only drift check (writes nothing).
lockthe recipe's sourcemultiplenoFetches and pins the selected versions in pekit.lock without building. With no version flags, reports the current lock instead.
workspacea delegated command across members(delegated)(delegated)Runs one of the above across every workspace member.

gen and verify are the source-generating pair — see Generating source below. The rest of this page's target model applies to build/test/install/clean; gen targets have their own shape.

workspace is a wrapper: it parses a delegated command after its own flags and runs it for each member. Its capabilities are exactly the delegated command's. See Workspaces.

Version behaviour #

build, package, publish, and lock iterate over every version the selector resolves to, running the whole plan once per version. test and install require the selector to resolve to a single version; if more than one resolves, pekit stops before doing any work:

invalid_version_selector: test requires a single resolved version

clean, gen, and verify never resolve versions or materialise a source tree — they operate on the recipe directly (the output directory, or the committed tree at the recipe root), so version-selection flags are not accepted (see the capability matrix below).

Command / capability matrix #

Each command accepts a fixed set of flag groups. Passing a flag the command does not support is an error up front (unsupported_flag), unless you pass --allow-unused, which downgrades it to a suppressed warning. Broadly: build, test, install, package, and publish share the version-selection, local-source, --no-build, --no-verify, and --refresh-source groups; --all is package/publish/gen/verify only; --allow-unanchored and --allow-unsigned are publish only; clean takes only --env, --keyring, and its own mode flags (--output-only / --target-only); gen and verify take only --env, --keyring, and --all; lock takes version selection, --refresh-source, and its own --repin. The full command-by-flag matrix is in the command-line reference.

Global flags (--dry-run, --quiet, --verbose, --json, --recipe, --allow-unused) are accepted by every command and are covered in the invocation reference.

The target model #

build, test, install, and clean each read a same-named section from the recipe. A section holds one or more targets, and a target is a shell command plus optional metadata. (gen targets live in a [gen.*] section too, but have a different shape — see below.)

Bare (main) targets #

A bare section — with a command key directly on it — defines a single target named main:

[build]
command = "make -j$(nproc)"

[test]
command = "make check"

pekit build with no selector runs build.main; pekit test runs test.main. The keys allowed directly on a bare section are command, needs, clear_out, and (for [build] only) dependencies.

Named targets #

Give a section sub-tables to define named targets, [<command>.<name>]:

[build.lib]
command = "make lib"

[build.tools]
command = "make tools"
needs    = ["lib"]

A target name must be a canonical selector: one or more of the characters A-Z a-z 0-9 _ . + -, and it may not start with - or contain / or :. An invalid name is rejected at load time (invalid_selector).

Bare and named cannot mix #

Within one section you use either the bare form or named sub-tables, never both. Putting a command key on the section and also adding a sub-table that is not a recognised target key is an error:

mixed_targets: cannot mix bare [build] target with named [build.foo]

Target selection #

When you name selectors (positional arguments, or arguments after --), pekit selects exactly those targets; each must exist in the section or the run fails:

pekit build lib tools
missing_target: unknown build target "libz"; available targets: lib, tools

With no selector, selection follows these rules for the command's section:

SituationResult
Section has a main targetmain is selected.
No main, command is build, exactly one target existsthat lone target is selected.
No main, and none of the aboveerror, listing the available targets.
Section is absent / emptymissing_target: recipe has no <command> targets.

The single-target fallback is build-only. For test and install, a bare pekit test with no test.main and multiple test targets is ambiguous:

ambiguous_target: no test.main target; available targets: fast, full

Build dependencies (needs) #

A target's needs lists build target names that must be staged first.

  • For build, the selected targets and everything reachable through needs are gathered and run in dependency order (a target runs after everything it needs). Each build runs at most once per invocation.
  • For test, install, and package, pekit pulls the build targets named in the selected target's needs (for package, the builds list on the package member), stages those builds first in dependency order, then runs the selected test/install targets (or writes packages).

A needs entry that does not name an existing build target is an error:

missing_target: test.full needs missing build target "core"

Dependency edges form a DAG. A cycle is detected and reported with the path:

target_cycle: build dependency cycle: a -> b -> a

--no-build may name already-staged build targets to skip re-running them; naming a build target that does not exist is likewise a missing_target error.

clean #

clean has two independent effects, gated by two mutually exclusive mode flags:

InvocationRuns clean targetRemoves output dir
pekit clean (default)yes, if clean.main (or the named selector) existsyes
pekit clean --output-onlynoyes
pekit clean --target-onlyyesno

Details:

  • Default. Runs clean.main if present (if there is no clean.main and no selector, the target step is simply skipped — not an error), then removes the recipe's managed output directory (out_dir).
  • --output-only. Removes the output directory only; no target runs. Because no target runs, --env and --keyring are unused and rejected (unsupported_flag), unless --allow-unused downgrades them to warnings.
  • --target-only. Runs the clean target only; the output directory is left in place. With no clean.main and no explicit selector this is an error: missing_target: --target-only requires clean.main or an explicit clean target.

clean accepts at most one target selector; more than one is rejected:

invalid_selector: clean accepts at most one target selector

--output-only and --target-only cannot be combined.

--all for package and publish #

A multi-package recipe has several package members. package and publish select members the same way targets are selected — by naming them, with an optional :instance suffix for multi-package enum instances — with two conveniences:

  • With no selector and a single member, that member is used; with no selector and multiple members, the run is ambiguous and lists the members.
  • --all selects every member.

--all is exclusive with explicit selectors:

invalid_flags: --all cannot be combined with package selectors

On any command other than package or publish, --all is an unsupported flag.

Generating source (gen and verify) #

Some recipes have source that is generated and committed to the tree — language bindings from a canonical header, a table baked from a schema, a file derived from another. That is not a build: a build reads source and writes artifacts under out_dir; a generator reads canonical inputs and writes generated source back into the tree. pekit models it as a separate command, gen, with a matching drift check, verify.

A gen target runs at the recipe root and its product is the files it writes in-tree. Its $PEKIT_OUT is a pekit-managed scratch directory (<out_dir>/.scratch/gen/<name>, cleaned on success, kept on failure) that sits outside the artifact area — packaging and --no-build reuse never see it. It is a handy place for a verify_command to regenerate into.

[gen.bindings]
# Runs at the recipe root; writes generated source in place.
command = "./tools/gen-bindings.sh"

# Optional drift gate. Exit 0 = in sync, non-zero = stale. It owns its own
# comparison — pekit never diffs for you and never assumes the generator is
# deterministic. A common shape regenerates into the scratch $PEKIT_OUT and
# diffs against the committed tree:
verify_command = """
./tools/gen-bindings.sh --out "$PEKIT_OUT/fresh"
diff -ru generated "$PEKIT_OUT/fresh"
"""

# Scope of the pre-flight (see below). Omit to gate every build/test.
verify_on_build = ["thing-that-embeds-bindings"]
verify_on_test  = []

[gen.bindings.dependencies.apt]
python3 = "*"

The keys allowed on a [gen.<name>] target are command (required), verify_command, verify_on_build, verify_on_test, dependencies, and verify_dependencies. Gen targets have no needs and no build DAG — they are independent of the build chain. Bare [gen] means gen.main, exactly like the other sections; pekit gen --all / pekit verify --all operate on every gen target.

  • pekit gen <name> runs command — regenerate in place.
  • pekit verify <name> runs verify_command — check only, never writes the tree. This is the on-demand / CI drift gate. Naming a gen target that has no verify_command is an error. A bare pekit verify selects gen.main, exactly like the other commands; pekit verify --all runs every gen target that has a verify_command, and errors only when none does.

Pre-flight drift checking #

The point of verify_command is that a consuming command never proceeds from a stale tree by accident. Before build, test, install, package, or publish runs, pekit runs the drift gates scoped to what that command will do, and fails fast if any is stale:

gen_out_of_date: gen target "bindings" is out of date: ...
  fix:  pekit gen bindings
  skip: pekit build thing --no-verify=bindings

Scope is per namespace, and the two keys are separate because a [build.x] and a [test.x] can share the bare name x:

verify_on_build / verify_on_testMeaning
absentGate every target in that namespace (the safe default).
[] (empty)Gate nothing — the drift check runs only via pekit verify.
["a", "b"]Gate only when target a or b in that namespace runs.

A gen target's gate fires when its verify_on_build gates a build the command will run, or its verify_on_test gates a test it will run. package and publish gate conservatively against every build target (they run builds under the hood, and the exact set depends on package resolution). Multiple stale gates are collected and reported together, not one at a time.

Set verify_on_build = [] for a generator whose output nothing builds (a downstream SDK, say): leaving it absent would drag the generator's toolchain into every unrelated build's pre-flight for no benefit. Use the scoped list form when only some targets actually consume the generated output.

--no-verify and verify_dependencies #

--no-verify opts out of the pre-flight for one invocation:

  • pekit build --no-verify skips all drift gates.
  • pekit build --no-verify=bindings,other skips only the named gens (an unknown name is an error).
  • On package / publish, --no-verify skips the same pre-flight — those commands gate conservatively against every build target, as above.

By default verify_command runs with the gen target's dependencies. If it needs a different (usually smaller) toolchain — a cheap hash check rather than a full regen — declare [gen.<name>.verify_dependencies.*]; when present it fully replaces dependencies for the verify run.

Selector rules #

Selectors are the command's positional arguments plus everything after a -- separator. Two rules apply to all of them:

  • A selector may not start with -. A leading dash makes pekit treat the value as a (probably mistyped) flag; put it after -- instead:

    invalid_selector: selector "-lib" starts with '-'; use -- before selector-like values
    
    pekit build -- -lib
    
  • Target selectors must be canonical (A-Z a-z 0-9 _ . + -, no leading -, no / or :). Package selectors follow the same grammar but allow a single : to separate a member from an enum instance.

See also #

  • Invocation and flags — the grammar, flag value forms, and global flags.
  • Command-line reference — the full command-by-flag capability matrix.
  • Versions — the version selectors these commands accept.
  • Workspaces — running a command across every member.

Invocation and flags

pekit / Running pekit

This page is the reference for pekit's command line: the grammar, how flag values are written, the flags every command accepts, and how pekit decides which recipe to run. For what each command does and how it selects targets, see Commands and targets.

Grammar #

pekit [global flags] <command> [args and flags]
pekit [global flags] workspace [workspace flags] <command> [args and flags]

There is exactly one command per invocation, drawn from build, test, install, package, publish, clean, gen, verify, lock, and workspace. Anything before the command that is not a recognised flag is an error (unknown_command / unknown_flag); a missing command is missing_command.

Flags may appear before or after the command. Pekit parses flags the same way on both sides of the command word, so these are equivalent:

$ pekit --dry-run build
$ pekit build --dry-run

The one structural exception is workspace, whose tail is parsed specially: the workspace-only flags (--fail-fast, --jobs) must come before the delegated command, and everything after the delegated command is parsed as an ordinary invocation of that command. See Workspaces.

-- stops flag parsing #

A bare -- ends flag parsing. Every token after it is captured verbatim and treated as a positional selector:

$ pekit build -- ./tools ./libs

Positional selectors may not begin with -. A leading-- token before -- is read as a flag (and rejected as unknown if it is not one); after -- it is still rejected as a selector with the diagnostic "use -- before selector-like values". Use -- for selectors that merely look option-like (for example a path), not to pass a selector that begins with a dash.

Flag value forms #

Flags fall into three shapes — no-value, required-value, and optional-value — and the shape decides how a value may be written. Which shape each flag takes is tabulated per flag in the command-line reference; the rules are as follows.

No-value flags (--dry-run, --json, and the like) never take a value: --flag=x is an error (unexpected_flag_value).

Required-value flags accept either the separated form (--recipe path) or the = form (--recipe=path). With the separated form pekit consumes the next token as the value — unless that token is missing, is --, or itself begins with -, in which case the flag errors with missing_flag_value. With the = form an empty value (--recipe=) is rejected the same way.

Optional-value flags take a value only through =. They never consume the following token. So:

$ pekit build --local              # --local with an empty value
$ pekit build --local=/src/mypkg   # --local with a value
$ pekit build --local /src/mypkg   # --local (empty) + /src/mypkg as a selector

-V is an alias for --version and behaves identically (it is a required-value flag; it is not a version-print flag — pekit has no version banner and no --help).

Per-key keyring values use a dotted-path form, --keyring.<path>=<value>, which always requires the = and a non-empty path.

Global flags #

Seven flags are parsed in any position regardless of command: --recipe, --workspace, --allow-unused, --dry-run, --quiet, --verbose, and --json. Six are accepted by every command; the seventh, --workspace, is only valid with the workspace command. Each is covered in its own section below, and the command-line reference tabulates all seven with their value forms. The rest of pekit's flags (--version/-V, --latest, --local, --env, --keyring, --all, and so on) are command-specific and are covered on Commands and targets.

--recipe <path|dir> selects the recipe directly instead of searching upward from the cwd (see How a recipe is located); --workspace <path|dir> does the same for the workspace file.

Mutual-exclusion rules #

Validation rejects contradictory combinations up front, before any work runs: --quiet cannot be combined with --verbose or --json; --recipe cannot be combined with --workspace (and --workspace is rejected outside the workspace command); the version selectors (--version / --latest / --all-versions), the local-source flags (--local / --prefer-local), and the clean modes (--output-only / --target-only) are each mutually exclusive within their group. The complete validation table, with each diagnostic, is in the command-line reference.

--allow-unused #

Every command declares which flags it accepts. Passing a recognised flag that the effective command does not support is normally an error (unsupported_flag), for example version selection on clean, or --all on build. --allow-unused turns each such case into a non-fatal notice: pekit records it and emits an unused_suppressed event at the start of the run instead of aborting.

--allow-unused only relaxes the "recognised-but-unused" check. It does not excuse:

  • unknown flags (unknown_flag) or unknown commands (unknown_command);
  • malformed flag values — missing values, empty = values, or =value on a no-value flag;
  • the mutual-exclusion rules above;
  • bad selectors (a selector beginning with -, or more selectors than the command accepts).

This is what lets you fan a shared set of flags across a whole workspace even when some members' commands ignore some of the flags.

--dry-run #

--dry-run runs the same planning pipeline as a real invocation — it locates and loads the recipe, resolves the version selector, and prepares source state — but stops at every side-effecting boundary instead of crossing it. In place of the action pekit emits a plan event:

  • a target that would run emits target_plan ("would run target") and does not execute the command or touch the stage directory;
  • clean that would delete managed output emits clean_plan ("would remove managed output") and removes nothing;
  • package / publish emit their plan events rather than writing or shipping artifacts.

Use --dry-run to inspect exactly what a command would do. Combine it with --json to get the plan as a single structured object (below).

Output renderers #

The renderer is chosen from the flags. --json selects the JSON renderer; otherwise the human renderer is used, and --quiet puts it in quiet mode.

Human (default) #

Pekit's own events print as timestamped lines, [15:04:05] message, optionally prefixed with a context tag (member, command, target, package, version) when the event carries one. Live output from the targets pekit runs is streamed through with a [member target version] prefix, preserving each line's original stream — stdout stays on stdout, stderr stays on stderr. Fatal errors are written to stderr.

--quiet #

Quiet mode keeps the human format but drops routine progress, emitting only warning, artifact, publish, workspace_summary, lock, and sign events. Target output is unaffected. --quiet cannot be combined with --verbose or --json.

--verbose #

Verbose mode adds extra events (such as per-target stage preparation) to the human log. It is otherwise the human renderer.

--json #

--json emits newline-delimited JSON — one event object per line — to stdout, so the run can be consumed by another program. Each object has a type and a timestamp plus whatever context fields apply; target output arrives as target_output events carrying the stream and text. Errors are emitted as an error event.

--dry-run --json is special-cased: instead of streaming events, pekit buffers them and, at the end, prints a single plan object:

{"type":"plan","time":"...","command":"build","workspace":false,"events":[ ... ]}

The events array holds the buffered plan events; command is the effective command and workspace is whether the run went through the workspace command.

Exit codes #

Pekit uses two exit codes only: 0 on success, 1 on any error — bad invocation, missing recipe, failed build, and so on. There are no finer-grained codes, and there is no --help or help command: this page, Commands and targets, and the command-line reference are the manual.

How a recipe is located #

Pekit resolves exactly one recipe per (non-workspace) run:

  1. --recipe <path|dir> — if given, this wins. A directory means <dir>/pekit.toml; a file path is used as-is. A missing target is missing_recipe.
  2. A remote positional — if --recipe was not given and the first positional argument looks like a remote locator, it is taken as the recipe (and dropped from the selector list).
  3. Walking up — otherwise pekit searches from the current directory upward for the nearest pekit.toml, stopping at the filesystem root (not_found if none is found).

Once the recipe root is known, and unless --workspace was supplied, pekit also walks upward from that root for a workspace.pekit.toml; if one is found the recipe is treated as a member of that workspace.

Remote recipe locators #

A remote locator is a recipe reference that points at a git repository rather than a local path. A value is treated as remote when it does not start with . or / and it either begins with github.com/, contains ://, ends with .git, or contains a // subdirectory separator. Examples:

$ pekit build github.com/acme/tools
$ pekit build github.com/acme/tools//subdir@v1.2.0
$ pekit build https://git.example.org/acme/tools.git//pkg@main

The locator may carry a //subdir to select a directory inside the repository and an @ref (branch, tag, or commit) to pin a revision. When pekit sees a remote locator — whether as the positional or as the value of --recipe — it materialises it: it mirror-clones the repository into a cache under the system temp directory (fetching updates if already cached), checks out the resolved commit into a clean working tree, and then treats the checkout (plus any //subdir) as the recipe root. From there the run proceeds exactly as it would for a local recipe.

See also #

  • Commands and targets — what each command does and selects.
  • Command-line reference — every flag, its value form, and the validation table.
  • Workspaces — the workspace tail and its flag placement.
  • Sources — what materialising a remote recipe's source involves.

Workspaces

pekit / Running pekit

A workspace lets you drive a whole collection of recipes as one unit. Instead of running pekit build in each recipe directory by hand, you declare which recipes belong together in a workspace.pekit.toml file and run a single pekit workspace build, which delegates that command to every member. This is how a distro-scale tree of packages — dozens or hundreds of recipes — is built, tested, packaged, and published in one go.

A workspace does three things:

  1. Discovers members from include/exclude globs (each member is a directory containing a pekit.toml).
  2. Shares configuration — [env], [wrap], and a distro-wide [policy] — down to every member.
  3. Schedules and delegates a single command across all members, with optional concurrency, fail-fast, and cross-member safety checks.

The workspace.pekit.toml file #

The workspace file is TOML and lives at the root of the tree it governs. Its schema is small — exactly five top-level keys are accepted, and anything else is rejected with unknown_key: include and exclude (globs selecting and removing member directories, relative to the workspace root), [env] and [wrap] (shared configuration contributed to every member), and [policy] (distro-wide derivation policy — see Policy). The key-by-key schema is in Supporting files.

include is mandatory: an omitted or empty include fails with missing_key ("workspace include is required"). Everything else is optional.

# workspace.pekit.toml at the distro root
include = ["core/*", "extra/**"]
exclude = ["extra/experimental/*"]

[env]
SOURCE_DATE_EPOCH = "1700000000"

[wrap]
command = ["nice", "-n", "10", "{{command}}"]

[policy.symbol_versions]
"libc.so.6" = "GLIBC_"

Member discovery #

include and exclude are glob patterns matched against the directory tree under the workspace root. Matching uses doublestar globbing, so ordinary wildcards (*, ?, character classes) work and ** matches across directory boundaries. Patterns are always workspace-root-relative.

Discovery proceeds as:

  1. Every exclude pattern is expanded and its matches recorded.
  2. Every include pattern is expanded. A match is kept only if it was not excluded and the matched directory contains a pekit.toml.

A matched directory with no pekit.toml is silently ignored — it is simply not a recipe, so it is not a member. This means you can point include at a broad glob (extra/**) and only the directories that actually hold recipes become members.

If discovery yields no members at all, the run fails with empty_workspace ("workspace has no members").

Note

Members are recipe directories, identified by the presence of a pekit.toml. The workspace file itself is named workspace.pekit.toml; it is a different file from a recipe.

Locating the workspace #

When you run pekit workspace, pekit finds the workspace file by walking upward from the current directory looking for workspace.pekit.toml (the same upward search recipes use). You can override this with --workspace <path>, which accepts a path to a workspace.pekit.toml file, a directory containing one, or a remote locator (for example a github.com/... reference).

Member identity #

Each member has a stable id: its workspace-relative directory path, written with forward slashes (for example core/zlib or extra/tools/ripgrep). This id is the member's name everywhere it appears:

  • in diagnostics and error messages (member_failed, member_skipped);
  • in structured JSON events and in output-line prefixes;
  • in the publish-collision owner labels (see below);
  • in the end-of-run summary.

Members are processed in a deterministic order: ids are sorted lexicographically. With the default single-worker execution this is the exact order members run in; with concurrency it is the order in which work is dispatched.

Invocation #

pekit [global flags] workspace [workspace flags] <command> [args and flags]

workspace is itself a command, but it does no building of its own — it delegates another command — any of build, test, install, package, publish, clean, gen, verify, or lock — to every member. That delegated command and its arguments come after the workspace flags.

Strict flag placement #

The tail after workspace is parsed specially, and the placement rules are strict:

  • Workspace flags — --jobs N and --fail-fast — must appear after workspace and before the delegated command. They are recognised only in this slot.
  • Command-level flags (version selection, --local, --env, --keyring, --refresh-source, --all, …) belong to the delegated command and must come after it. Placing one before the delegated command is an error with a hint: "workspace command flag <name> must appear after the delegated command."
  • Global flags (--dry-run, --quiet, --verbose, --json, --allow-unused, --workspace) may appear either before workspace or in the tail before the delegated command.
# correct: workspace flags before the command, command flags after
$ pekit workspace --jobs 4 --fail-fast build --version 1.2.3

# also correct: a global flag may precede workspace
$ pekit --dry-run workspace build

# error: --version is a command flag placed before the delegated command
$ pekit workspace --version 1.2.3 build
#   workspace command flag version selection must appear after the delegated command

Because --jobs/--fail-fast are recognised only in the workspace slot, writing them after the delegated command hands them to the delegated command's own parser, which rejects them as an unknown_flag. Keep them in the workspace slot.

Both failures share the diagnostic code missing_workspace_command: it fires when a command flag precedes the delegated command, and when no delegated command follows the workspace flags at all ("workspace requires a delegated command").

Execution model #

Each member is run as an ordinary per-recipe invocation of the delegated command — exactly as if you had cd'd into that member and run pekit <command> ... there, with the delegated flags and any positional selectors carried through unchanged.

Concurrency: --jobs #

--jobs N sets how many members run concurrently. N must be a positive integer; a missing value is missing_flag_value and a non-positive or non-numeric value is invalid_flag_value ("--jobs requires a positive integer"). The default is 1, i.e. members run one at a time in id order.

Fail-fast: --fail-fast #

By default the workspace attempts every member even if some fail: it runs them all and then fails the overall command at the end if any member failed (workspace_failed, "N workspace member(s) failed").

With --fail-fast, the first member failure sets a stop flag so that no further members are started. Members that had not yet begun are recorded as skipped.

Note

--fail-fast stops starting new members; it does not cancel members that are already running. In-flight members run to completion. There is no mid-member cancellation.

No inter-member dependencies #

Members are independent. Pekit does not compute a dependency graph between members, does not topologically order them, and does not make one member's build visible to another as an input. The only ordering is the lexicographic id order used for dispatch. If a member needs another member's output, that relationship must be expressed through the normal recipe machinery (sources, package dependencies), not through workspace membership.

Summary #

At the end of a run pekit emits a workspace_summary event tallying members as succeeded, failed, or skipped:

12 succeeded, 1 failed, 3 skipped

Succeeded and failed count members that actually ran; skipped covers members not started because of --fail-fast, and members skipped by selector planning (below). The overall command exits non-zero if any member failed.

Per-member strictness #

The delegated command and its flags are validated once, when the tail is parsed. Beyond that, strictness is evaluated per member, because members differ: one recipe may define a build target or a package selector that another does not.

  • Each member runs the delegated command against its own recipe. If a flag or selector cannot be honoured by that member's recipe, that member errors — its failure is reported (member_failed) and counted, and the other members are unaffected (unless --fail-fast is set).

  • --allow-unused relaxes this per member. When a run carries positional selectors (target names, or def:variant package selectors) together with --allow-unused, pekit plans which selectors apply to which member:

    • selectors that a member cannot satisfy are suppressed for that member (reported via workspace_selector_suppressed);
    • a member to which none of the requested selectors apply is skipped ("no requested selectors apply to member");
    • if a requested selector applies to no member at all, the whole run fails with missing_selector ("workspace selector … matches no selected member").

    --allow-unused also suppresses per-member unsupported version selection rather than failing that member.

Without --allow-unused, selectors are passed to every member verbatim and a member that does not recognise one fails in the usual way.

Cross-member publish collision detection #

When the delegated command is publish, two members could be configured to publish an artifact to the same destination — a mistake that would have them overwrite each other. Pekit catches this before publishing anything.

A preflight pass runs each member's publish planning in dry-run mode and reserves every resolved destination path in a shared registry. Destinations are keyed by path, each with an owner of the form <member-id>:<instance>. Reserving a path that a different owner already holds fails the entire run with publish_collision ("publish destination collision between … and …"). Reserving the same path for the same owner is idempotent, so a member does not collide with itself.

Because this is a preflight, a destination clash is reported up front, before any member has written to a publish target.

Shared configuration #

[env] and [wrap] #

[env] and [wrap] declared at the workspace level are shared defaults contributed to every member, layered together with each member recipe's own [env] and [wrap]. For how these compose with recipe- and environment-file values — and the precedence between them — see Environments and keyrings. The [wrap] command follows the same {{command}} placeholder rules as a recipe wrap. Keyrings behave similarly: a named --keyring on a workspace run resolves against the workspace root once, and the values are shared with every member.

Policy: symbol-version floors #

The [policy] table carries distro-wide derivation policy. Its one current sub-table is [policy.symbol_versions], a map from a shared-library soname to the symbol-version token prefix whose tokens are commensurable with the providing package's version:

[policy.symbol_versions]
"libc.so.6" = "GLIBC_"
"libstdc++.so.6" = "GLIBCXX_"

This governs which sonames receive a symbol-version floor when pekit derives a package's dependencies across the workspace — so that, for example, a binary using a GLIBC_2.34 symbol gains an appropriate lower bound on its libc dependency. An unknown sub-table under [policy] is rejected (unknown_key, "policy.… is not a known policy table"). For how derived dependencies and claims work, see Dependencies and claims.

Where to go next #

For the recipes a workspace's members are, read Anatomy of a recipe.

For how workspace [env] and [wrap] compose with a recipe's own, read Environments and keyrings.

For the full flag surface and remote locators, read Invocation and flags.

Signing and provenance

pekit / Running pekit

A published package is a claim: these bytes were built from these inputs, by this recipe, with this tool. Pekit backs that claim end to end. On the input side, fetched sources are pinned trust-on-first-use in pekit.lock and upstream release signatures can be verified against committed keys — both covered on the Sources page. This page covers the output side: signing the artifacts pekit writes, the provenance recorded in every manifest, the corresponding-source package that keeps a build's exact inputs publishable, and the checks publish runs before shipping anything.

Signing artifacts #

Package signing is configured through one well-known keyring entry, signing.package_key, whose value is the path to an Ed25519 private key — either the raw 32-byte seed or a PKCS#8 PEM as produced by openssl genpkey -algorithm ed25519. A relative path resolves against the invocation's working directory. Because it rides the keyring mechanism, the key path lives in a per-developer, gitignored file — or arrives inline as --keyring.signing.package_key=<path> — and private key material never enters a recipe.

$ pekit publish --keyring=dev

With a key configured, every peipkg-format artifact the run produces — package members and the corresponding-source package alike — is packed with an embedded signature naming the key's fingerprint (the lowercase hex SHA-256 of the raw 32-byte public key), and each emits a sign event. Sign events stay visible under --quiet, so a quiet run still shows what was signed with which key.

Two failure behaviours are deliberate:

  • A configured key that cannot be loaded is a hard signing_key error. Pekit never falls back to silently writing unsigned output — a signing setup that degrades quietly would defeat the point.
  • Without a key, package writes unsigned artifacts (fine for local development), but publish refuses peipkg-format packages with unsigned_publish unless you pass --allow-unsigned.

Signing binaries for PIP #

Package signing protects the artifact in transit; it says nothing to the kernel about the programs inside. The Peios kernel derives a process's Process Integrity Protection tier from a second, separate signature carried by the binary itself, and pekit produces that signature too — as a step of the build target, not of packaging.

A build target lists the files to sign in a sign table, keyed by signature kind. The pip kind maps paths or globs relative to the target's $PEKIT_OUT to the keyring entry that holds the signing key:

[build.main]
command = "make && make install DESTDIR=$PEKIT_OUT"

[build.main.sign.pip]
"usr/bin/peinit"  = "tcb.priv"
"usr/lib/*.so.*"  = "tcb.priv"
# dev.keyring.pekit.toml — per-developer, gitignored
[tcb]
priv = "kacs-tcb-dev.key"     # openssl genpkey -algorithm ML-DSA-65

The value names any keyring leaf; there is no fixed entry name. It holds the path to an ML-DSA-65 private key — PKCS#8 PEM, or a raw 32-byte seed — resolved against the invocation's working directory when relative. The kind decides only the format and the storage location: for pip that is the fixed 3310-byte blob in an ELF section named .peios.sig.

Signing runs after the target's command exits 0 and before any dependent target or package sees the output, so it is the last thing to touch the binary. That ordering is why this is a build step: the signature covers the whole file, so every strip, debug split or patchelf a recipe performs has to come first, and those all live in build commands. It also means pekit test exercises the signed binary rather than an unsigned stand-in.

For each matched file, pekit:

  1. Adds a zero-filled .peios.sig section, or reuses one the build already reserved (it must be SHT_PROGBITS and exactly 3310 bytes). Adding a section appends to the file and never moves existing bytes, so program headers and loadable segments are unaffected.
  2. Hashes the file with the section contents zeroed, signs the hash with pure ML-DSA-65 (deterministic, empty context) and writes the blob into the reserved bytes.
  3. Verifies the finished file the way the kernel will before returning, and emits a sign event naming the keyring entry and the key's fingerprint — the SHA-256 of the raw public key, the same bytes a kernel key table carries.

Two properties are deliberate:

  • Every failure is fatal. A keyring entry that is missing or does not load, a pattern that matches nothing, a file that is not a 64-bit little-endian ELF, a reserved section of the wrong size, and two patterns naming different keys for one file all abort the target. There is no --allow-unsigned equivalent, because an unsigned binary is not a weaker binary: it runs with no tier and no diagnostic, and build time is the only place the mistake is visible.
  • Only the ELF section form is produced. The specification also allows the signature in an extended attribute, but package payloads carry no extended attributes, so nothing pekit ships could use it. Scripts and other non-ELF files take their interpreter's tier and cannot be usefully signed.

Which tier a signature confers is a property of the key, not of anything in the recipe: the kernel looks the verifying key up in its compiled-in table. Getting a key into that table is a kernel-build question — see the tcb.pub entry the pkm recipe consumes — and a binary signed with a key the kernel does not carry is simply unsigned there.

What the manifest records #

Every peipkg manifest pekit writes carries a build block stating where the artifact came from:

FieldValue
timestampThe run's start time, UTC RFC 3339.
farm_idlocal — pekit builds are local builds.
source_refThe source provenance ref: git:<url>@<commit>, url:<url>#sha256:<hash>, and so on.
recipe_refThe recipe tree's own git commit, git:<commit>. Suffixed +dirty when the work tree has uncommitted changes anywhere — including a freshly written, not-yet-committed pekit.lock. Omitted when the recipe is not inside a git work tree.
builderThe producing pekit's own revision: pekit/<12-hex-commit>, +dirty when built from a modified tree, falling back to the module version when the binary carries no VCS stamp.
source_packageThe name of the corresponding-source package emitted from this recipe (below); empty when none is.

Together these state the full provenance chain — the exact upstream inputs, the exact recipe that drove the build, and the exact tool that performed it. recipe_ref deliberately resolves the enclosing repository: a workspace member's identity is the workspace repository's commit, not some per-recipe notion. A dirty tree is over-marked rather than under-marked, because a commit id alone does not describe a build whose tree had local changes.

Corresponding-source packages #

A recipe with a reproducible source automatically emits a corresponding-source package whenever it packages peipkg-format members. This is how the distribution meets copyleft source-availability obligations: publishing a binary and its source package through the same channel keeps the exact inputs of every published build available for as long as the binary is.

One source package covers every peipkg member the recipe produces. It is emitted when all of these hold:

  • source_package.enabled is not set to false (emission is the default);
  • the source actually materialised as git with a resolved commit or url — a local override never qualifies, and neither does a git source built from a bare branch ref with no selected version (a deliberately moving target has no stable corresponding source);
  • at least one emitted member has format = "peipkg".

The package is named <recipe-dir>-source by default (rename with source_package.name), is noarch, and is versioned identically to the members — members that disagree on version are a source_package_version_conflict error. Its license is the conjunction of the members' distinct licenses, joined with AND; its homepage is kept only when every member agrees on one; and it publishes to the first member's [publish] destinations.

Everything installs under /usr/src/dist/<name>-<version>/ (with any -source suffix stripped from <name>):

  • upstream/ — the pristine source input. For a url source, the downloaded artifact byte-for-byte, so its hash matches the committed pekit.lock; for a git source, a git archive export of the locked commit — deterministic for a commit and independent of the mutable checkout.
  • patches/ — the recipe's patch series, when one is declared. The applied series is the shipped series by construction, and it ships under the fixed patches/ name even when [source].patches picks a different directory.
  • recipe/ — the build-controlling files from the recipe directory: pekit.toml, pekit.lock, package definitions (including packages.pekit/), env files, and keys/. *.keyring.pekit.toml files are never included — developer key material stays out.

Each binary member's manifest names the source package in build.source_package, linking every binary to its corresponding source. The recipe-side schema is in the recipe format reference.

What publish checks before shipping #

publish is packaging plus shipping, and the shipping half is gated:

  • Unsigned peipkg packages are refused (unsigned_publish) unless --allow-unsigned is passed, as above.
  • Unanchored provenance is refused (unanchored_provenance) unless --allow-unanchored is passed. Unanchored means a url source with neither a checksum nor a lock entry — a state a real resolve immediately locks away, so in practice this gates dry-run publish plans for never-fetched sources. A plain package from an unanchored source warns instead.
  • An existing destination file is refused (publish_exists) when the target's overwrite = false.
  • Two artifacts resolving to the same destination in one run is a publish_collision; across workspace members, the publish preflight catches the same collision before any member ships anything.

Where to go next #

For the input side of the chain — the lockfile, --repin, and upstream signature pinning — read Sources.

For the keyring files that carry the signing key, read Environments and keyrings.

For every flag mentioned here, read the command-line reference.

Recipe format reference

pekit / Reference

This is the normative schema for the two TOML files at the heart of a pekit recipe: pekit.toml (the recipe proper) and package.pekit.toml (what to package). It enumerates every section, every key, its type, whether it is required, and its meaning. Behaviour that a key merely triggers is cross-linked to the concept pages; this page is the field-by-field contract.

Two strictness rules hold throughout and are called out per-section below:

  • Unknown keys are hard errors. Every table the loader owns has a fixed set of accepted keys. Any other key — including a camelCase spelling of a known snake_case key, such as outDir — is rejected with an unknown_key diagnostic. There is no lenient/forward-compatible mode.
  • Types are checked on decode. A key declared here as a string must be a string, a table must be a table, and so on; a mismatch is an invalid_type error.

pekit.toml #

Worked example #

out_dir = "dist"

[env]
CFLAGS = "-O2"
PREFIX = "/usr"

[wrap]
command = ["pesb", "run", "--", "{{command}}"]

[source.git]
url = "https://github.com/example/tool.git"
ref = "v{{version}}"
versions = ">=1.0"
tag_regex = "^v(?P<version>[0-9.]+)$"

[build]
command = "make PREFIX=$PREFIX"

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

[build.linked]
command = "make link"
dependencies.pesb-dev.libc = ">=2.38"

[test]
command = "make check"

[install]
command = "make install DESTDIR=$PEKIT_OUT"

[clean]
command = "make clean"

Top-level keys #

The recipe file accepts exactly these top-level keys. Anything else is an unknown recipe key error.

KeyTypeRequiredMeaning
out_dirstringnoOutput directory name, relative to the recipe root. Default "out".
envtablenoEnvironment variables exported to every target command. See [env].
wraptablenoCommand wrapper applied to every target. See [wrap].
sourcetablenoWhere the recipe's source tree comes from. See [source].
delegatebool or tablenoBorrow build/env/wrap/package definitions from the source tree. See [delegate].
source_packagetablenoCorresponding-source package emission control. See [source_package].
buildtablenoBuild target(s). See [build] / [test] / [install] / [clean].
testtablenoTest target(s).
installtablenoInstall target(s).
cleantablenoClean target(s).
gentablenoSource-generating target(s). See [gen].

[env] #

A flat table of environment-variable name → value. Every entry is exported into each target command's environment (layered on top of the PEKIT_* contract; see Anatomy of a recipe).

KeyTypeRequiredMeaning
<NAME>string—Value for variable NAME.

Each NAME must match ^[A-Za-z_][A-Za-z0-9_]*$; anything else is an invalid_env error. Values must be strings. Declaration order in the file is preserved, so a later entry may reference an earlier one via shell expansion in the target command.

[wrap] #

Wraps every target command in another command (for example, running it inside a sandbox launcher).

KeyTypeRequiredMeaning
commandstring or array of stringsnoThe wrapper. Must contain the placeholder {{command}} exactly once.

No other key is permitted under [wrap]. The {{command}} rules mirror a target command:

  • String form: the whole string is run through a shell; it must contain the substring {{command}} exactly once.
  • Array form: {{command}} must appear as a complete argument (not a substring of a larger argument) exactly once, and never as the first element (element 0 is the wrapper program itself).

An omitted command yields an empty wrap (no wrapping).

[source] #

Selects and configures the source tree. It has three sub-tables. At most one reproducible source (git or url) may be present — declaring both is a mixed_source error. A local source may be combined with a reproducible one (the local path acts as an override). Unknown keys directly under [source] are rejected.

Sub-tableMeaning
[source.git]Clone from a git repository.
[source.url]Download from a URL (optionally an archive).
[source.local]Use a directory on disk.

One direct key is accepted alongside the sub-tables:

KeyTypeRequiredMeaning
patchesstringnoName of a recipe-root directory (a single path segment) whose series file pekit applies to the materialised source tree before any target runs. Requires a reproducible source (patches_source otherwise). See Patches.

See Sources for materialisation, caching, and provenance behaviour. Resolved git and url sources are pinned trust-on-first-use in the recipe's machine-written pekit.lock.

[source.git] #

KeyTypeRequiredMeaning
urlstringyesGit remote URL to clone.
refstringnoRef (tag/branch/commit) to check out. Templated. Default "{{version}}".
versionsstringnoVersion cap: a constraint string filtering enumerated or requested versions (see Versions).
tag_regexstringnoRegex extracting version numbers from tag names during enumeration.

[source.url] #

KeyTypeRequiredMeaning
urlstringyesURL to download. Templated with the version.
extractboolnoTreat the download as an archive and extract it. Default false.
rootstringnoSub-directory within the extracted tree to use as the source root. Default ".".
versionsstringnoVersion cap: a constraint string filtering enumerated or requested versions (see Versions).
file_regexstringnoRegex extracting version numbers when enumerating from a listing.
checksumstring or tablenoExpected checksum. A bare string applies to all versions; a table maps version → checksum.
signaturetablenoUpstream signature verification — see [source.url.signature] below.

[source.url.signature] #

Optional sub-table of [source.url]. Its presence makes upstream signature verification required: the detached signature is fetched and verified against the pinned keys before a version is locked or built. A missing signature is signature_missing; a failed verification is signature_invalid; either aborts the run and writes no lock entry. Verification is in-process OpenPGP — no host gpg is involved.

KeyTypeRequiredMeaning
key_filesstring arrayyesCommitted public key files (armoured or binary OpenPGP), resolved relative to the recipe root. Must be non-empty.
urlstringnoSignature URL template. {{source_url}} expands to the rendered artifact URL; version variables are also available. Default "{{source_url}}.sig".
ofstringnoWhat the signature covers: "artifact" (the published file, default) or "decompressed" (its decompressed content — kernel.org's .tar.sign signs the uncompressed tar). Any other value is invalid_signature.
fingerprintsstring arraynoAllowlist of signer fingerprints (hex; spaces and 0x ignored, case-insensitive). When set, a valid signature by any other pinned key is signature_untrusted_key.

[source.local] #

KeyTypeRequiredMeaning
pathstringyesPath to the source directory, resolved relative to the recipe root.

[delegate] #

Lets a recipe borrow definitions that live inside the fetched source tree rather than in the recipe directory. May be written as a bare boolean (delegate = true, equivalent to all = true) or as a table.

KeyTypeRequiredMeaning
allboolnoDelegate everything (build, env, wrap, packages).
buildboolnoDelegate build targets.
envboolnoDelegate [env].
wrapboolnoDelegate [wrap].
packagesboolnoDelegate package definitions.

Any other key is an unknown delegate key error. A category is delegated when either its own flag or all is set.

[source_package] #

Controls the corresponding-source package a recipe emits alongside its binary packages. Emission is automatic — the table exists only to opt out or rename. A recipe emits one when both of these hold: it has a reproducible [source] (git or url; local overrides and bare branch refs never qualify), and it produces at least one peipkg-format package.

KeyTypeRequiredMeaning
namestringnoPackage name. Default <recipe-dir>-source.
enabledboolnoSet false to emit no source package. Default true.

Any other key is an unknown source_package key error.

The emitted package is noarch, versioned identically to the recipe's package members (members that disagree on version are a source_package_version_conflict error), licensed as the conjunction of the members' licenses, and installs under /usr/src/dist/<name>-<version>/ (with any -source suffix stripped from <name>):

  • upstream/ — the pristine source input: a url source's downloaded artifact byte-for-byte, so its hash matches the committed pekit.lock, or a git archive export of the locked commit.
  • patches/ — the recipe's patch series, when a patches/ directory exists.
  • recipe/ — the build-controlling files from the recipe directory: pekit.toml, package definitions (including packages.pekit/), env files, pekit.lock, and keys/. *.keyring.pekit.toml files are never included.

Every peipkg-format member the recipe emits carries the source package's name in its manifest's build.source_package field, linking each binary to its corresponding source.

[build] / [test] / [install] / [clean] {#targets} #

These four sections define targets — the commands pekit runs. Each section takes one of two shapes, distinguished by whether a command key is present directly in the section:

  • Bare target: command (and the other target keys) sit directly under the section, e.g. [build] with command = "make". This defines a single target named main.
  • Named targets: the section contains sub-tables, e.g. [build.docs] and [build.linked], each a target. Each name must be a valid selector.

Mixing the two — a bare command alongside a named sub-table in the same section — is a mixed_targets error. (A sub-table whose key is itself a target key, such as [build.dependencies], is read as part of the bare target, not as a named target.)

Fields of a single target:

KeyTypeRequiredMeaning
commandstring or array of stringsyesThe command. String form runs through a shell; array form is an argv executed directly. An empty array is rejected.
needsarray of stringsnoNames of other targets in the same section that must run first.
clear_outboolnoWipe this target's output directory before running. Default true.
dependenciestablenoBuild targets only. Build-time dependencies the target needs provisioned. See below.
signtablenoBuild targets only. Files in the target's output to sign after the command succeeds, by signature kind. See sign below.

Any other key in a target table is an unknown target key error. dependencies and sign are accepted only in [build] targets; using either in test/install/clean is an unknown key.

The dependencies table is keyed by provider (a selector), each mapping dependency names to version constraints:

[build.linked.dependencies]
pesb-dev.libc = ">=2.38"
pesb-dev."pkgconfig(zlib)" = "*"
LevelTypeMeaning
providertableA dependency-provider selector.
provider.<name>stringVersion constraint for the capability name. Must be non-empty; use "*" for any version.

Dependency names may be real package names or virtual capabilities (sonames, pkgconfig(...), etc.) and are validated against peipkg's capability grammar. See Commands and targets.

[build.<name>.sign] {#target-sign} #

The sign table is keyed by signature kind. Each kind is a table mapping a path or glob, relative to the target's $PEKIT_OUT, to the dotted path of the keyring entry holding the signing key:

[build.main.sign.pip]
"bin/peinit"  = "tcb.priv"
"lib/*.so.*"  = "tcb.priv"

The kind decides the signature format and where it is stored; the keyring entry is resolved through the ordinary keyring mechanism when the target runs. Signing happens after the target's command exits 0 and before any dependent target or package sees the output.

LevelTypeMeaning
kindtableA signature kind. The only kind is pip — the Peios binary signature that confers a Process Integrity Protection tier. Any other name is unknown_key.
kind.<pattern>stringA relative path or glob (the same glob syntax as [files]). The value is a keyring leaf path such as tcb.priv whose value is the path to the private key. Must be non-empty.

For pip, the key is an ML-DSA-65 private key — PKCS#8 PEM as produced by openssl genpkey -algorithm ML-DSA-65, or a raw 32-byte seed — and every matched file must be a 64-bit little-endian ELF. A pattern that matches nothing (sign_target_missing), a keyring entry that is absent or does not load (signing_key), a file that cannot carry the signature (sign_failed), and two patterns naming different keys for one file (sign_conflict) are all errors; pekit never leaves a listed file unsigned. The mechanism is described in Signing and provenance.

[gen] {#gen} #

A [gen] section defines source-generating targets: they run at the recipe root and write generated source into the tree rather than producing an out_dir artifact. Bare/named shapes work exactly as for the target sections above ([gen] is gen.main; [gen.<name>] names a target). Gen targets have their own fields — they take no needs or clear_out:

KeyTypeRequiredMeaning
commandstring or array of stringsyesThe generator. Runs at the recipe root; writes generated source in place.
verify_commandstring or array of stringsnoThe drift gate: exit 0 = in sync, non-zero = stale. Run by pekit verify and by the consuming-command pre-flight.
verify_on_buildarray of stringsnoBuild target names this gate applies to. Absent gates every build; [] gates none; a list gates only those.
verify_on_testarray of stringsnoAs verify_on_build, for the test namespace.
dependenciestablenoBuild-time dependencies for command (and for verify_command, unless overridden). Same shape as a build target's dependencies.
verify_dependenciestablenoWhen present, fully replaces dependencies for the verify_command run.

Any other key is an unknown target key error. Setting verify_on_build, verify_on_test, or verify_dependencies without a verify_command is an invalid_gen error (there is nothing to gate).

A gen target's $PEKIT_OUT is a pekit-managed scratch directory (<out_dir>/.scratch/gen/<name>) outside the artifact area, cleaned on success and retained on failure — a convenient place for verify_command to regenerate into for comparison. For the command surface, the pre-flight, and --no-verify, see Commands and targets.

package.pekit.toml #

The package file describes one or more package artifacts to emit from the built source. The same schema applies to the base file (package.pekit.toml) and to per-member member files (<name>.package.pekit.toml); see Multi-package recipes and Supporting files for how the files are discovered and layered.

Worked example #

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

[package]
name = "tool"
version = "{{version}}"
architecture = "x86-64-v3"
description = "An example tool"
license = "MIT"
homepage = "https://example.com/tool"
default_root = "opt.tool"

[dependencies]
libc = ">=2.38"
zlib = { constraint = ">=1.3", root = "opt.tool" }

[optional_dependencies]
libcurl = ">=8.0"

[provides]
tool = "{{version}}"

[files]
"main:bin/tool" = "usr/bin/tool"
"@recipe:share/" = { path = "usr/share/tool/", override = true }

[symlinks]
"usr/bin/t" = "tool"

excludes = ["@recipe:share/*.tmp"]

[claims.provides.editor.default]
path = "usr/bin/editor"
target = "usr/bin/tool"

[[publish.localdir]]
path = "dist"
overwrite = false

Top-level keys #

The package file accepts exactly these top-level keys; anything else is an unknown package key error.

KeyTypeRequiredMeaning
formatstringnoArtifact format: "tar" or "peipkg". Default "tar".
clear_outboolnoWipe the package stage directory before building. Default true.
buildsarray of stringsnoNames of build targets this package requires before staging.
packagetablenoPackage metadata. See [package].
filestablenoPayload file mappings. See [files].
symlinkstablenoSymlinks to embed in the payload. See [symlinks].
excludesarray of stringsnoRefs/globs to drop from directory payloads. See excludes.
multipacktablenoExpand into a family of instances. See [multipack].
publishtablenoWhere to copy finished artifacts. See [publish].
dependenciestablenoRuntime dependencies. See [dependencies].
optional_dependenciestablenoOptional runtime dependencies (same schema as dependencies).
conflictstablenoname → constraint of packages this one conflicts with.
providestablenoname → version of capabilities this package provides.
replacestablenoname → constraint of packages this one replaces.
side_effectsarray of stringsnoDeclared install-time side effects.
sd_overridestablenopath → SDDL security-descriptor overrides.
claimstablenoClaim slots on provides/dependencies. See [claims].

Important

The tar format carries payload only: it cannot express manifest metadata. If a tar package sets version, architecture, description, license, homepage, or any of dependencies, optional_dependencies, conflicts, provides, replaces, side_effects, or sd_overrides, the build fails with unsupported_manifest_field. The peipkg format requires [package].version, [package].architecture, and [package].license — the license is optional at the format level, but pekit requires it so every package states its redistribution terms and the corresponding-source package can derive its own.

Most string-valued fields (metadata, dependency names/constraints, file refs and destinations, claim paths/targets) are run through the template engine, where {{version}} and {{multipack}} are available. See Versions and Multi-package recipes.

[package] #

Package metadata. Accepts exactly these keys; any other is an unknown package metadata key error. All values are strings.

KeyTypeRequiredMeaning
namestringnoPackage name. Defaults to the package selector (suffixed with the multipack value for multipack instances) when omitted.
versionstringnoPackage version. Required for peipkg.
architecturestringnoTarget architecture. Required for peipkg.
descriptionstringnoHuman-readable description.
licensestringnoLicense identifier. Required for peipkg.
homepagestringnoProject homepage URL.
default_rootstringnoPreferred named root for a top-level install. Must be a dotted named reference matching [a-z0-9][a-z0-9_-]* per segment, never a filesystem path (a / is rejected).
special_system_packagebooleannoDeclares the package exempt from the payload layout rules, for the rare package whose job is to lay down the structure those rules protect — the base-filesystem package that mints the mountpoint tree is the archetype. Setting it disables pekit's layout validation for this package. It grants nothing at install time: whoever installs or composes the result must also pass --dangerously-bypass-path-restrictions, so a recipe can propose its own exemption but never grant one. Defaults to false.

Note that dependencies, provides, conflicts, and the rest are top-level tables, not sub-keys of [package].

[dependencies] #

Runtime dependency constraints. [optional_dependencies] has the identical schema. Each entry is either a bare constraint string (the short form) or a table that additionally places the dependency in a named root.

FormTypeMeaning
<name> = "constraint"stringVersion constraint; the dependency lands in the depender's own root.
<name> = { constraint, root }tableTable form (below).

Table form keys — only these two are accepted (unknown_key otherwise):

KeyTypeRequiredMeaning
constraintstringnoVersion constraint. Omitting it matches any version.
rootstringnoNamed root to place the dependency in. Same grammar as default_root.

Dependency names may be real or virtual capabilities. See Dependencies and claims.

[files] #

Maps a source ref (the key) to a destination path within the package payload (the value). The value is either a string destination or a table.

Value formMeaning
"ref" = "dest/path"Copy the ref to dest/path.
"ref" = { path, override }Table form (below).

Table form keys — only these are accepted:

KeyTypeRequiredMeaning
pathstringyesDestination path in the payload. Must be non-empty.
overrideboolnoMark the payload as an override entry (skips manifest payload validation for peipkg). Default false.

The source ref selects where the file is read from. A bare path with no prefix is resolved against the recipe root (or the layer's owner). Explicit forms: @recipe:<path>, @source:<path>, @workspace:<path>, and <build-target>:<path> (reading a build target's output; :<path> alone uses the main build target). Destinations ending in / and glob patterns are supported. See Packages for ref resolution and glob semantics.

[symlinks] #

Maps a destination path within the payload (the key) to a symlink target (the value).

Value formMeaning
"usr/bin/x" = "target"Create a symlink at usr/bin/x pointing at target.
"usr/bin/x" = { target, override }Table form (below).

Table form keys — only these are accepted:

KeyTypeRequiredMeaning
targetstringyesSymlink target text. Must be non-empty.
overrideboolnoMark as an override entry. Default false.

excludes #

An array of refs/globs. During directory payload expansion, files matching an exclude pattern (resolved against the same root as the file mapping being expanded) are dropped from the payload. Patterns use the same ref forms as [files] keys.

[multipack] #

Expands one definition into a family of package instances, one per enumerated value. The only accepted key is enum, which takes one of two shapes:

Explicit list:

[multipack]
enum = ["gtk3", "gtk4"]

Each value must be a valid selector, non-empty, and unique.

Enumerated from files:

[multipack.enum.files]
path = "@source:themes/*/theme.ini"
regex = "^(?P<value>.+)$"

[multipack.enum.files] accepts exactly path and regex, both required:

KeyTypeRequiredMeaning
pathstringyesA ref glob (templated) whose matches are enumerated.
regexstringyesRegex applied to each match's basename. Must have exactly one capture group, or a single named value group, which supplies the instance value.

See Multi-package recipes.

[publish] #

Where finished artifacts are copied. The only accepted target is localdir, written as an array of tables:

[[publish.localdir]]
path = "dist"
overwrite = false

Each entry accepts only path and overwrite:

KeyTypeRequiredMeaning
pathstringyesDestination directory, relative to the workspace root (or recipe root outside a workspace). Templated. The artifact keeps its own filename.
overwriteboolnoAllow replacing an existing file at the destination. Default true.

[claims] #

Attaches claim slots to provides and dependency entries (a claim is a symlink slot a provider fills or a consumer expects). The [claims] table has two sides — provides and dependencies — each keyed by the provides or dependency name, then by slot, then to a descriptor. Any side other than provides/dependencies is an unknown_key error.

[claims.provides.<name>.<slot>]
path = "usr/bin/editor"
target = "usr/bin/tool"

[claims.dependencies.<name>.<slot>]
path = "usr/bin/editor"

A slot descriptor accepts exactly these two keys (anything else is rejected); both are strings and both are templated:

KeyTypeRequiredMeaning
pathstringnoThe symlink location (set on a consumer, or as a provider default).
targetstringnoThe holder file within this package (set on a provider). A provider's target must point at a file the package ships.

Provider-side slots (claims.provides) attach to matching [provides] entries by name; consumer-side slots (claims.dependencies) attach to matching [dependencies]/[optional_dependencies] entries. See Dependencies and claims and the Peios claims model for the resolution behaviour.

See also #

  • Supporting files — the workspace, env, keyring, lockfile, and patch-series schemas.
  • Anatomy of a recipe — the narrative walk-through of these files.
  • Packaging files — how [files] refs and destinations resolve.

Supporting files and reference tables

pekit / Reference

This page documents the TOML files that sit alongside a recipe — the workspace file, env files, and keyring files — and gives exhaustive reference tables for the three substitution systems used throughout pekit: template variables, source-ref roots, and publish targets.

All files are TOML. Every loader rejects unknown top-level keys with an unknown_key diagnostic, so the key tables below are complete: any key not listed is an error.

For the recipe file itself (pekit.toml / *.pekit.toml), see recipe-format.


workspace.pekit.toml #

A workspace file marks a directory as a pekit workspace and declares distro-wide defaults.

KeyTypeRequiredMeaning
includearray of stringsyesMember patterns the workspace builds. Must be present and non-empty, or the load fails (missing_key).
excludearray of stringsnoPatterns removed from the include set.
[env]tablenoWorkspace-level environment variables. See env table.
[wrap]tablenoWorkspace-level command wrapper. See wrap table.
[policy]tablenoDistro-wide derivation policy. See below.

[policy] #

The [policy] table currently carries exactly one sub-table. Any other key under [policy] is rejected.

KeyTypeRequiredMeaning
[policy.symbol_versions]table (string → string)noMaps a shared-library soname to the symbol-version token prefix whose tokens are commensurable with the providing package's version. Governs which sonames get a symbol-version floor during dependency derivation.

Each entry in [policy.symbol_versions] is soname = "PREFIX_":

[policy.symbol_versions]
"libc.so.6"     = "GLIBC_"
"libstdc++.so.6" = "GLIBCXX_"

env.pekit.toml #

An env file provides a named, selectable layer of environment variables, command wrapper, and dependency-provider override.

An env file must declare at least one of [env], [wrap], or dependency_provider; a file with none of them is rejected (missing_key).

KeyTypeRequiredMeaning
[env]tableone of the threeEnvironment variables. See env table.
[wrap]tableone of the threeCommand wrapper. See wrap table.
dependency_providerstringone of the threeSelector naming which of a build target's declared dependency-provider blocks ([build.<target>.dependencies.<provider>]) is exported as the PEKIT_DEPENDENCIES* variables. Validated as a selector.

--env <name> selection #

The active env file is chosen from the --env <name> flag (default main), resolved relative to the recipe root:

--env valueFile loadedMust exist?
(unset) or mainenv.pekit.tomlno (silently absent)
none(no env file loaded)n/a
any other <name><name>.env.pekit.tomlyes — a missing file is an error

So --env prod loads prod.env.pekit.toml, and it must exist. The default env.pekit.toml is optional.

[env] table #

Shared by workspace.pekit.toml, env.pekit.toml, and the recipe's own [env]. It is a flat table of NAME = "value" string pairs.

RuleDetail
Value typeEvery value must be a string.
Name grammarNames must match ^[A-Za-z_][A-Za-z0-9_]*$.
Reserved prefixUser env may not set any PEKIT_* name — those are pekit-managed. A PEKIT_-prefixed name is rejected at build time (reserved_env).
LayeringApplied workspace → (delegated source) → recipe → env file, later layers overriding earlier ones.
ExpansionValues are shell expressions, not literals. $NAME expands when the target runs, so a value may reference a managed PEKIT_* variable or an env value declared before it. Values are word-split-safe: spaces are preserved, so a flag list needs no quoting of its own.
[env]
CC = "clang"
CFLAGS = "-O2 -pipe"
CXXFLAGS = "$CFLAGS -Wp,-D_GLIBCXX_ASSERTIONS"
TOOL_PATH = "$PEKIT_ROOT/tools"

$ always expands — a literal $ is not expressible here, and \$ does not escape it. A value needing one (an $ORIGIN rpath, say) belongs in the target's command, where the recipe has the full shell.

[wrap] table #

Shared by workspace.pekit.toml, env.pekit.toml, and the recipe. Wraps every target command in another command (sandbox, nice, a toolchain shim, etc.).

KeyTypeRequiredMeaning
commandstring or array of stringsnoThe wrapper. Must contain exactly one {{command}} placeholder.

{{command}} rules:

  • String form — the string must contain {{command}} exactly once.
  • Array form — exactly one element must be the literal {{command}} (as a complete argument, not a substring), and it may not be element 0 (the program). An element that merely contains {{command}} inside other text is rejected.
[wrap]
command = ["bwrap", "--ro-bind", "/usr", "/usr", "{{command}}"]

Note: {{command}} here is a wrap-only placeholder handled by the wrap machinery. It is not one of the template variables below and is not available in ordinary recipe strings.


*.keyring.pekit.toml #

A keyring file supplies secrets to the build environment as PEKIT_KEYRING_* variables. Keyrings are opt-in per invocation (--keyring); a keyring named <name> resolves to <name>.keyring.pekit.toml, searched in the workspace root then the recipe root (a path-like value is used verbatim).

Schema #

The file is a tree of nested TOML tables whose leaves are strings. Each leaf's dotted path is converted into an environment-variable name:

  • Prefix PEKIT_KEYRING_, then the dotted path uppercased.
  • Every run of non-alphanumeric characters (including the . separators) collapses to a single _.
  • Trailing _ is trimmed.
Leaf pathExported variable
tokenPEKIT_KEYRING_TOKEN
github.tokenPEKIT_KEYRING_GITHUB_TOKEN
registry.api-keyPEKIT_KEYRING_REGISTRY_API_KEY
# placeholders only — never commit real secrets
token = "REPLACE_ME"

[github]
token = "REPLACE_ME"

[registry]
api-key = "REPLACE_ME"

Restrictions #

RuleDetail
Leaf typeEvery leaf must be a string. A non-string leaf is an error.
NestingSub-tables nest arbitrarily; each nesting level adds a segment to the exported name.
Typed entriesA table containing a path or content key is a typed entry and is not supported yet (unsupported_keyring_entry).
CollisionsA keyring export that collides with a normal or managed env variable is an error (env_collision).

Well-known entries #

Most keyring values are opaque to pekit — they exist to be exported. The entries below are additionally read by pekit itself:

Leaf pathMeaning
signing.package_keyPath to an Ed25519 private key — a raw 32-byte seed, or PKCS#8 PEM as produced by openssl genpkey -algorithm ed25519. When present, every peipkg-format artifact the run produces — package members and -source packages alike — is packed with an embedded signature naming the key's fingerprint, and each emits a sign event. A relative path resolves against the invocation's working directory. A configured key that cannot be loaded is an error (signing_key); pekit never silently falls back to unsigned output.

Without a configured signing key, package writes unsigned artifacts, and publish refuses peipkg-format packages unless --allow-unsigned is passed (unsigned_publish).

A build target's sign table reads further entries, but which ones is the recipe's choice: each sign.pip value is the dotted path of a leaf holding the path to an ML-DSA-65 private key. Those entries are still exported to the target like any other.


pekit.lock #

The machine-written source lockfile, created and updated by pekit in the recipe directory beside pekit.toml. It pins fetched source inputs trust-on-first-use: the first resolve of a version records what was fetched, and every later resolve verifies against the record — see Sources for the behaviour. Commit it, and do not edit it by hand: pekit lock --repin --version <v> is the one supported way to change an existing entry. Loading is strict — an unknown key is unknown_key and an unsupported schema is lock_schema.

Schema #

KeyTypeMeaning
schemaintegerLockfile schema version. Currently 1.
[[source]]array of tablesOne entry per locked version, kept sorted by version.

Each [[source]] entry carries the keys for its source kind:

KeySet forMeaning
versionallThe locked version. Empty for a versionless url fetch.
urlurl sourcesThe rendered URL fetched at lock time. Provenance only — the hash, not the address, is the assertion.
sha256url sourcesSHA-256 of the fetched artifact.
refgit sourcesThe rendered ref the version resolved through.
commitgit sourcesThe commit the ref resolved to — the assertion.
signature_keyurl sources with [source.url.signature]Hex fingerprint of the pinned upstream key that verified the artifact at lock time.
locked_atallUTC timestamp of the pinning run (RFC 3339).

Worked example #

schema = 1

[[source]]
  version = "0.5.12"
  url = "https://example.org/dash-0.5.12.tar.gz"
  sha256 = "6a474ac46e8b0b32916c4c60df694c82058d3297d8b385b74508030ca4a8f28a"
  signature_key = "9f0b7ddc1325a3e2c40e5f0a88b1c62f3d94ab07"
  locked_at = "2026-08-10T11:00:00Z"

[[source]]
  version = "1.2.0"
  ref = "v1.2.0"
  commit = "8f3a1bc99e2f6d41c07b21ac1886d63b7f2e5d10"
  locked_at = "2026-08-10T11:00:00Z"

The patches directory #

The directory named by [source] patches = "<dir>" in pekit.toml (conventionally patches, and always a single path segment). pekit applies its series to the materialised source tree before any target runs — see Sources for the behaviour — and the whole directory ships in the recipe's source package under patches/.

The series file #

<dir>/series lists the patches in apply order: one slash-separated relative path per line. # starts a comment, whole-line or trailing; blank lines are ignored; entries must not contain whitespace. Subdirectories are allowed (misc/fix.patch).

Loading is strict:

ConditionError
series missing or unreadablepatch_series
An entry that repeats, contains whitespace, or escapes the directorypatch_series
A series entry with no file behind itpatch_missing
A *.patch file in the directory the series does not listunused_patch (permitted by --allow-unused)

Application is git apply per entry, in series order, with no fuzz: a failed hunk is patch_apply and a patch that matches nothing is patch_skipped.

Worked example #

patches/
  series
  misc/
    mke2fs-root-sd.patch

with a series of:

# Applied in dependency order:
misc/mke2fs-root-sd.patch   # stamp the root inode's SD at format time

Give each patch file a short header — what it changes and its upstream status (submitted, backport, Peios-specific). The series ships with the corresponding source, where that header is a third party's only context.


Template variables #

Recipe and package value strings (source refs, ref, package metadata, file/symlink paths, excludes, claims, multipack enum.files.path, publish path) are run through the template engine before use. The syntax is {{name}}, where name matches [A-Za-z0-9_]+. An unknown name is an error.

VariableSourceAvailability
{{version}}the selected version, verbatim (Raw)Errors when no version is selected.
{{major}}major componentAvailable once a version is selected.
{{minor}}minor componentErrors if the selected version has no minor component.
{{patch}}patch componentErrors if the selected version has no patch component.
{{prerelease}}pre-release component (after -)Empty string when absent.
{{buildmeta}}build-metadata component (after +)Empty string when absent.
{{multipack}}the current multipack instance valueOnly available while expanding a multipack package's per-instance values; errors in any non-multipack context.

A version string parses as major[.minor[.patch]][-prerelease][+buildmeta], so {{minor}} / {{patch}} are only present when the version actually carries them.

Shell targets use $PEKIT_* instead #

The {{...}} engine applies to recipe configuration strings, not to the shell bodies of build/test/install/clean commands. Target commands instead read the pekit-managed environment. The version equivalents are:

Template variableEnvironment variable
{{version}}$PEKIT_VERSION
{{major}}$PEKIT_VERSION_MAJOR
{{minor}}$PEKIT_VERSION_MINOR
{{patch}}$PEKIT_VERSION_PATCH
{{prerelease}}$PEKIT_VERSION_PRERELEASE
{{buildmeta}}$PEKIT_VERSION_BUILDMETA

Alongside these, the managed environment also exports PEKIT_RECIPE_ROOT, PEKIT_SOURCE_ROOT, PEKIT_LITERAL_ROOT, PEKIT_ROOT, PEKIT_OUT_BASE, PEKIT_OUT, PEKIT_COMMAND, PEKIT_TARGET, PEKIT_WORKSPACE_ROOT, PEKIT_BUILD_TIMESTAMP, PEKIT_SOURCE_TIMESTAMP, a PEKIT_<DEP>_OUT per declared needs dependency, and any PEKIT_KEYRING_* from active keyrings. The full environment contract is documented in the command-line reference.


Source-ref roots #

Package files sources, excludes, and multipack.enum.files.path are references whose leading token selects which root the path is resolved against. A reference is ROOT:path (or a bare path, which normalises to the owner's default root).

Reference formRootMeaning
target:paththe build target's output stageReads from the output of build target target.
:pathbuild target main's output stageSame as above with the target name defaulted to main.
@source:paththe materialised source tree (LiteralRoot)Reads from the checked-out / extracted source.
@recipe:paththe recipe directoryReads a file shipped next to the recipe.
@workspace:paththe workspace rootReads a file from the workspace. Errors if used outside a workspace.
bare pathowner defaultA path with no : normalises to the owning layer's root: @recipe: for recipe packages, @source: for source packages, @workspace: for workspace packages.

Any reference containing a : that is not one of the @-prefixed roots is a build reference; the token before the : is the target name. See packages for how these are used in a package's [files] table.


Publish targets #

The publish command copies a package's built artifact to declared destinations. There is exactly one publish kind: localdir. Any other [publish.*] key is rejected (unknown_key). A package selected for publishing that declares no target is an error.

Targets are an array of tables:

[[publish.localdir]]
path = "dist/{{version}}"
overwrite = false
KeyTypeRequiredMeaning
pathstringyesDestination directory, relative to the base root. Templated with {{version}} and {{multipack}}. The artifact keeps its own filename.
overwriteboolno (default true)Whether an existing destination file may be replaced. When false, an existing destination is an error (publish_exists).

Resolution details:

AspectBehaviour
Base rootThe workspace root when building in a workspace, otherwise the recipe root. path is joined onto it.
Final destination<base>/<path>/<artifact-filename>.
CollisionsTwo instances resolving to the same destination is an error (publish_collision) unless they are the same artifact.

Selectors #

Several names above are validated as selectors: target names, package selectors, multipack values, and dependency_provider. A selector must match ^[A-Za-z0-9_.+-]+$, must not be empty, must not start with -, and must not contain / or :.

See also #

  • Recipe format reference — the pekit.toml and package-file schemas.
  • Environments and keyrings — how env and keyring files are selected and layered.
  • Signing and provenance — signing.package_key and pekit.lock in the trust chain.

Command-line reference

pekit / Reference

This is the exhaustive reference for pekit's command line and the environment it exports to the commands it runs. Every flag listed here is a real flag accepted by the parser; there are no others. For a narrative walk-through of the grammar and recipe location, see Invocation and flags; for what each command does, see Commands and targets.

Synopsis #

pekit [global flags] <command> [args and flags]
pekit [global flags] workspace [workspace flags] <command> [args and flags]

<command> is exactly one of build, test, install, package, publish, clean, gen, verify, lock, or workspace. Flags may appear before or after the command word and are parsed identically on both sides — the sole exception is the workspace tail (see Workspace flags). A bare -- ends flag parsing; every following token is captured verbatim as a positional selector. Positional selectors may not begin with -.

There is no --help and no help command, and no version banner: -V is an alias for the --version selector, not a print-version flag.

Flag value forms #

The shape of a flag determines how its value may be written.

ShapeHow a value is written
No-valueNever takes a value. --flag=x errors with unexpected_flag_value.
Required-value--flag value or --flag=value. A missing next token, a next token that is -- or begins with -, or an empty --flag= all error with missing_flag_value.
Optional-valueValue only via --flag=value. The bare --flag is valid and means an empty value; it never consumes the following token.

Global flags #

Accepted by every command, before or after the command word. These are never subject to the capability matrix below.

FlagValue formSemantics
--recipe <path|dir>Required-valueUse this recipe instead of searching upward from the cwd. A directory means <dir>/pekit.toml.
--workspace <path|dir>Required-valueUse this workspace file/root. Valid only with the workspace command.
--allow-unusedNo-valueDowngrade "command does not support this recognised flag" from a fatal error to a suppressed notice.
--dry-runNo-valueRun the full planning pipeline but stop before any side effect; emit plan events instead.
--quietNo-valueHuman renderer, routine progress suppressed (only warning, artifact, publish, workspace_summary, lock, sign events).
--verboseNo-valueHuman renderer plus extra staging/progress events.
--jsonNo-valueEmit newline-delimited JSON events on stdout instead of the human log.

The renderer is chosen from these flags: --json selects the JSON renderer (with --dry-run it buffers and prints a single plan object); otherwise the human renderer runs, in quiet mode when --quiet is set.

Version, source, and build flags #

These are command-specific: each is only accepted by the commands marked in the capability matrix. Passing one to a command that does not support it is unsupported_flag unless --allow-unused is set.

FlagValue formMeaning
--version <v>, -V <v>Required-valueSelect a single recipe version. -V is an exact alias.
--latestNo-valueSelect only the newest available version.
--all-versionsNo-valueAct on every available version.
--local[=<path>]Optional-valueUse a local source tree instead of resolving source; bare form uses the default, =<path> overrides it.
--prefer-local[=<path>]Optional-valueUse the local source when present, otherwise resolve normally.
--no-build[=<list>]Optional-valueReuse already-staged build targets. Bare form reuses all; =a,b,c reuses only the named build targets. Only affects build-kind targets whose stage already exists.
--no-verify[=<list>]Optional-valueSkip the gen drift-check pre-flight. Bare form skips all; =a,b,c skips only the named gen targets (an unknown name errors). On package/publish it passes through to the builds they trigger.
--env <name>Required-valueSelect the environment file layer. main (default) loads env.pekit.toml; <name> loads <name>.env.pekit.toml; none loads no env file.
--keyring <name|path>Required-valueLoad a keyring file. A bare name resolves to <name>.keyring.pekit.toml searched in the workspace root then recipe root; a path-like value is used directly. Repeatable.
--keyring.<path>=<value>Dotted, = requiredSet a single keyring value inline. Requires a non-empty dotted path and the =; otherwise invalid_keyring_flag. Repeatable.
--refresh-sourceNo-valueForce source re-materialisation, ignoring any source cache. The lockfile is still enforced against the fresh download.
--allow-unanchoredNo-valuePermit publish from source with unanchored provenance — no checksum and no lock entry (otherwise unanchored_provenance).
--allow-unsignedNo-valuePermit publish of peipkg-format packages without a configured signing key (otherwise unsigned_publish).
--repinNo-valuelock only: re-download the selected version and replace its lock entry — the explicit accept-changed-upstream-bytes ceremony. Requires an exact single --version.
--allNo-valuepackage/publish: act on every package definition instead of a selector. gen/verify: act on every gen target.
--output-onlyNo-valueclean: remove managed output only, without running a clean target.
--target-onlyNo-valueclean: run the clean target only, without removing managed output.

--version/-V, --latest, and --all-versions form one mutually-exclusive group ("version selection"); --local and --prefer-local form the "local source flags" group.

Workspace flags #

workspace delegates to another command. Its tail is parsed specially: the two workspace-only flags must appear after workspace and before the delegated command word. Everything after the delegated command is parsed as an ordinary invocation of that command.

FlagValue formMeaning
--jobs <N>, --jobs=<N>Required-value, positive integerMaximum concurrent members. Default 1. A non-integer or value <= 0 errors with invalid_flag_value.
--fail-fastNo-valueAbort the whole workspace run on the first member failure.

Global flags (--dry-run, --json, --recipe, …) may also appear in the tail before the delegated command. A version/source/build flag placed there is an error (missing_workspace_command, "workspace command flag … must appear after the delegated command"): those belong after the delegated command. The same code fires when no delegated command follows at all. See Workspaces.

Capability matrix #

Which command accepts which flag group. Global flags and workspace flags are omitted (global flags are accepted everywhere). A blank cell means the flag group is unsupported for that command.

Commandversion selectionlocal source--no-build--no-verify--env--keyring--refresh-source--allow-unanchored--allow-unsigned--allclean mode--repin
buildYesYesYesYesYesYesYes
testYesYesYesYesYesYesYes
installYesYesYesYesYesYesYes
packageYesYesYesYesYesYesYesYes
publishYesYesYesYesYesYesYesYesYesYes
cleanYesYesYes
genYesYesYes
verifyYesYesYes
lockYesYesYes

"clean mode" is the --output-only / --target-only pair.

Validation and mutual-exclusion rules #

The parser rejects contradictory or malformed invocations before doing any work.

Rejected conditionDiagnostic
--quiet with --verbose--quiet and --verbose cannot be used together
--quiet with --json--quiet and --json cannot be used together
--recipe with --workspace--recipe and --workspace cannot be used together
--workspace without the workspace command--workspace can only be used with the workspace command
No command wordmissing_command
More than one of --version / --latest / --all-versionsmutually exclusive
--local with --prefer-localmutually exclusive
--output-only with --target-onlymutually exclusive
A recognised flag the effective command does not supportunsupported_flag (suppressed under --allow-unused)
clean --output-only with --env or --keyring"clean --output-only does not run a target, so … is unused" (suppressed under --allow-unused)
clean with more than one selectorinvalid_selector (clean accepts at most one)
package/publish with --all and a selector"--all cannot be combined with package selectors"
gen/verify with --all and a selector"--all cannot be combined with gen/verify target selectors"
lock with any selectorinvalid_selector (lock does not accept selectors)
lock --repin without an exact single --versioninvalid_flags (--repin requires exactly one exact --version)
--no-verify naming an unknown gen targetmissing_target
A positional selector beginning with -invalid_selector (use -- before selector-like values)

--allow-unused relaxes only the "recognised-but-unused" and "clean --output-only unused env/keyring" checks; it never excuses unknown flags, malformed values, the mutual-exclusion rules, or bad selectors.

Exit codes #

CodeMeaning
0Success.
1Any error — bad invocation, missing recipe, failed target, and so on.

There are no other exit codes.

Environment contract #

Before running a target's command, pekit constructs its environment in layers: managed PEKIT_* variables, then keyring exports, then user-declared env (workspace → source → recipe → env-file). The process inherits the parent environment with these overlaid. User env may not define any PEKIT_* variable — doing so is reserved_env. The command runs with its working directory set to the source root (or the recipe root for a clean target).

Always set #

Exported for every target pekit runs.

VariableValue
PEKIT_RECIPE_ROOTDirectory containing the resolved recipe (pekit.toml).
PEKIT_SOURCE_ROOTRoot of the materialised source tree. Equals the recipe root for a non-delegated recipe.
PEKIT_LITERAL_ROOTRoot used to resolve @source: payload references.
PEKIT_ROOTPer-source pekit work base (staging lives beneath it).
PEKIT_OUT_BASEBase directory for this source's managed output.
PEKIT_OUTThis target's own stage directory (where it should write).
PEKIT_COMMANDThe command kind running the target (build, test, install, clean, gen, verify).
PEKIT_TARGETThe target's name.
PEKIT_BUILD_TIMESTAMPRun start time, Unix seconds.
PEKIT_SOURCE_TIMESTAMPSource provenance time, Unix seconds.
PEKIT_WORKSPACE_ROOTWorkspace root, or an empty string when not in a workspace (always set).
PEKIT_DEPENDENCIES_FILEPath to the JSON dependency payload pekit writes for this target.
PEKIT_DEPENDENCY_PROVIDERSelected env dependency provider (empty when none).
PEKIT_DEPENDENCIESResolved dependencies as name constraint lines, one per line (empty when none).

Set only when a version is selected #

Exported only when the run resolves a non-empty version.

VariableValue
PEKIT_VERSIONFull version string.
PEKIT_VERSION_MAJORMajor component.
PEKIT_VERSION_MINORMinor component.
PEKIT_VERSION_PATCHPatch component.
PEKIT_VERSION_PRERELEASEPre-release component (may be empty).
PEKIT_VERSION_BUILDMETABuild-metadata component (may be empty).

Per-dependency and per-keyring variables #

Variable patternWhen setValue
PEKIT_<NEED>_OUTOne per entry in the target's needsStage directory of that needed build target. <NEED> is the target name upper-cased with - and . replaced by _. Two needs mapping to the same name is env_collision.
PEKIT_KEYRING_<PATH>One per keyring leaf loaded via --keyring / --keyring.<path>=<value>The keyring value. <PATH> is the dotted keyring path upper-cased, with each run of non-alphanumeric characters collapsed to a single _ and trailing _ trimmed. For example --keyring.token=… exports PEKIT_KEYRING_TOKEN, and a nested [registry] token = … exports PEKIT_KEYRING_REGISTRY_TOKEN.

A keyring export that collides with a managed PEKIT_* variable or with a normal env variable is env_collision.

See also #

  • Invocation and flags — the narrative walk-through of the grammar.
  • Commands and targets — what each command selects and runs.
  • Environments and keyrings — the layers behind the environment contract.

Peios Learn — documentation for the Peios project.

Built with Trail.