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:
| Command | What it does |
|---|---|
build | Run build targets and their build dependencies. |
test | Stage the builds a test needs, then run test targets. |
install | Stage the builds an install needs, then run install targets. |
package | Stage builds and write .peipkg package artifacts. |
publish | Package, then publish the artifacts to a configured destination. |
clean | Run a clean target and/or remove pekit's managed output directory. |
gen | Run source-generating targets that write generated source into the tree. |
verify | Run gen targets' drift checks without writing anything. |
lock | Fetch and pin source inputs in pekit.lock without building. |
workspace | Run 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
.peipkgfiles 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.
What to know before you start #
A few facts about the tool as it ships today:
- No
--helpyet. 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
0on success and1on 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
pekitbinary on yourPATH. Pekit is a single binary built from its source repository withgo build ./cmd/pekit; there is no separate installer. Note that pekit has no--helpor version banner — the docs are the manual. - A POSIX shell (
sh). Target commands run undersh -euc; any Linux system has this. zstdandtar, 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"
[]
= '''
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_dirnames the directory pekit manages, relative to the recipe. Everything pekit stages or writes lands under it, andpekit cleanmay remove it wholesale — keep nothing precious there.[build]in this bare form defines a single build target namedmain. Itscommandis an ordinary shell script; pekit runs it with a set ofPEKIT_*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 runmake,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
= "peipkg"
[]
= "hello"
= "0.1.0-1"
= "noarch"
= "A friendly greeting."
= "MIT"
[]
= "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 fullPEKIT_*environment contract. - Commands and targets — the commands, named targets,
needsordering, target selection, and thegen/verifysource-generation pair. - Invocation and flags — the command line:
--dry-run,--json, and how recipes are located. - Recipe format reference — the exhaustive schema for
pekit.tomland 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:
| File | Purpose | Page |
|---|---|---|
pekit.toml | The recipe: where source comes from, how to build/test/install/clean it, shared env. | this page |
package.pekit.toml | The 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.toml | Reusable environment variables and a shell wrapper to run commands under. | Environments and keyrings |
*.keyring.pekit.toml | Named collections of secrets exported into the build as PEKIT_KEYRING_*. | Environments and keyrings |
workspace.pekit.toml | Sits 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"
[]
= "go build -trimpath -o \"$PEKIT_OUT/pekit\" ./cmd/pekit"
[]
= "go test ./..."
[]
= "go install ./cmd/pekit"
[]
= "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"
# Environment variables layered onto every target command.
[]
= "never"
# Wrapper: every target command runs inside this. {{command}} is substituted
# with the actual build script exactly once.
[]
= "nix develop --command sh -c {{command}}"
# Where the source tree comes from, and which versions exist.
[]
= "https://github.com/BurntSushi/ripgrep.git"
= "{{version}}"
= ">=13.0.0, <=14.1.1"
= '^(\d+\.\d+\.\d+)$'
# Borrow selected behaviour from a pekit.toml inside the fetched source.
[]
= true
# Named build targets. `needs` names other build targets to stage first.
[]
= "cargo build --release && cp target/release/rg \"$PEKIT_OUT/rg\""
[]
= "cp complete/_rg \"$PEKIT_OUT/_rg\""
= ["main"]
[]
= "cargo test --release"
= ["main"]
[]
= "cargo install --path . --root \"$PEKIT_OUT\""
[]
= "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 cleanmay remove it wholesale, so keep nothing precious there. It holds fetched sources, per-target staging directories, and written.peipkgfiles.[env]— a table ofNAME = "value"pairs exported into every target command. Names must be valid shell identifiers, and pekit reserves thePEKIT_prefix — you cannot set aPEKIT_*variable here. See Environments and keyrings.[wrap]— a singlecommandthat 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 borrowingbuild,env,wrap, orpackagesbehaviour from apekit.tomlfound inside the fetched source tree (ordelegate = truefor 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 withsh -euc(POSIX shell, with-eabort-on-error and-uunset-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 asPEKIT_<NAME>_OUT(see below).clear_out— whether pekit wipes this target's staging directory before running (defaulttrue).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):
[]
= "make"
…or in named form, one subtable per target:
[]
= "make"
[]
= "make man"
= ["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:
| Variable | Meaning | When set |
|---|---|---|
PEKIT_OUT | This target's own staging directory. Write your build output here. | Every target |
PEKIT_RECIPE_ROOT | Directory containing pekit.toml. | Every target |
PEKIT_SOURCE_ROOT | Root 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>_OUT | The 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 specificpekit.toml(or a directory containing one).- A remote locator — a
github.com/...path, a.gitURL, or ascheme://...URL, optionally with a@refand 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_OUTinto.peipkgpayloads. - Recipe format reference — the exhaustive
pekit.tomlschema.
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.
[]
= "https://github.com/example/widget.git"
= "v{{version}}"
= ">=2.0.0"
= "^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.
[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.
[]
= "https://ftp.example.org/widget/widget-{{version}}.tar.gz"
= true
= "widget-{{version}}"
= "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:
[]
= "sha256:aaaa..."
= "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.
[]
= "../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 withmissing_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-localwith 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:
- If the mirror does not exist,
git clone --mirror <url>; otherwisegit fetch --prune --tagsto 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. - Resolve
refto an immutable commit (git rev-parse <ref>^{commit}). - When a version is selected, verify the resolved commit against the version's lockfile entry — or create the entry on first resolve.
- Clone the mirror into
<out_dir>/git-<hash>/source, thengit reset --hard <commit>andgit clean -fdxto pin the checkout. - 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.
- Write a
source.pekit.jsonmanifest 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>/:
- If the artifact is not already cached, download it (with a
pekit/2user-agent). - If a
checksumis set, verify it; on mismatch, re-download once and verify again before failing. - 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. - Materialise the tree at
<out_dir>/url-<hash>/source: ifextractis set, extract the archive to a temporary directory, then promote therootsubdirectory (which must exist, elsemissing_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. - 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.
- Write a
source.pekit.jsonmanifest.
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 --mirrorand 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:
| Suffix | Handler |
|---|---|
.zip | native zip reader |
.tar | uncompressed tar |
.tar.gz, .tgz, .crate | gzip + tar (.crate is cargo's publish format — a plain gzipped tarball) |
.tar.bz2, .tbz2 | bzip2 + tar |
.tar.zst | zstd + tar |
.tar.xz, .txz | tar 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]:
[]
= "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 bytag_regex. If the regex has a capture group, group 1 is the version; otherwise an embeddedMAJOR.MINOR.PATCHis extracted. - url: fetch the directory listing derived from the
urltemplate (the part before the first{{…}}, up to the last/), filter entries byfile_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:
| Kind | Provenance ref |
|---|---|
| sourceless | recipe:<recipe-root> |
| local | local:<path> |
| git | git:<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):
= true
# or, selectively:
[]
= true
= true
= true
= 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 version | major | minor | patch | prerelease | buildmeta |
|---|---|---|---|---|---|
2 | 2 | ||||
2.43 | 2 | 43 | |||
2.43.1 | 2 | 43 | 1 | ||
1.21.0-rc.1 | 1 | 21 | 0 | rc.1 | |
1.21.0+build.5 | 1 | 21 | 0 | build.5 | |
1.21.0-rc.1+bld | 1 | 21 | 0 | rc.1 | bld |
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.)
| Variable | Expands 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 |
[]
= "https://github.com/example/tool.git"
= "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}}against2.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.
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.
| Flag | Selects | Needs an enumerable source? |
|---|---|---|
--version <selector> | exact version(s), or a semver constraint | only for constraints |
--latest | the single highest available version | yes |
--all-versions | every available version | yes |
| (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 write | Candidates tried, in order |
|---|---|
2.43.0 | 2.43.0, then 2.43 |
2.0.0 | 2.0.0, then 2.0, then 2 |
2.0 | 2.0, then 2 |
2.43.1 | 2.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:
[]
= "https://github.com/example/tool.git"
= "v{{version}}"
= ">= 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--latestreturns 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):
[]
= "usr/bin/hello"
= "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:
[]
= { = "usr/share/hello/layout.json", = 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:
[]
= "usr/bin/hc" # from build target "tools"
= "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:
| Prefix | Root |
|---|---|
@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). |
[]
= "usr/share/licenses/hello/LICENSE"
= "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:
[]
= "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.
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:
[]
= "usr/share/hello/icons/"
= "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/placesfoo.pngatusr/share/hello/icons/foo.png, andlib/**/*.sopreserves the sub-directory structure belowlib/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:
[]
= "usr/share/hello/"
= [
"@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:
| Ref | Destination | Result |
|---|---|---|
":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:
[]
= "hello"
= { = "libhello.so.1", = 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:
- Declared package dependencies —
[dependencies]and[optional_dependencies]you write in a package file. - Derived dependencies — sonames and pkgconfig capabilities pekit reads out of the staged binaries at pack time, added on top of what you declared.
- 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.
[]
= ">= 3.0"
= "*"
= ">= 1.2.11"
[]
= "*"
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:
[]
# short form: constraint only, depender's own root
= ">= 1.2"
# table form: constraint + explicit root
[]
= ">= 3"
= "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.pcfiles 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 field | Becomes | Meaning |
|---|---|---|
DT_SONAME | a provides entry | the object's own ABI name, e.g. libfoo.so.3 |
DT_NEEDED | a dependency entry | each 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
-develpackage'slibfoo.so -> libfoo.so.3development symlink is not opened; following it would read the target'sDT_SONAMEand 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
[]
= "GLIBC_"
= "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.34constraint on that dependency. Reserved, non-numeric tokens such asGLIBC_PRIVATEare 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.
[]
= "make"
[]
= ">= 13"
= "*" # "*" means any version; a build dep constraint may not be empty
[]
= "*"
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)
= "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):
| Variable | Contents |
|---|---|
PEKIT_DEPENDENCY_PROVIDER | the selected provider name |
PEKIT_DEPENDENCIES_FILE | absolute path to the JSON payload |
PEKIT_DEPENDENCIES | the 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.
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:
| Field | Set by | Meaning |
|---|---|---|
target | provider | the holder file this package ships that the shared name resolves to |
path | consumer (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
[]
= "1"
[]
= "/usr/bin/gcc" # a file this package ships
= "/usr/bin/cc" # provider default for the shared name
# consumer: some-toolchain.package.pekit.toml
[]
= "*"
[]
= "/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:
| Kind | Location | Selector |
|---|---|---|
| Base | package.pekit.toml, or packages.pekit/package.pekit.toml | main |
| 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.tomlin both the root andpackages.pekit/is an error (duplicate_package_base). - A member's selector is its filename with the
.package.pekit.tomlsuffix removed. A file literally namedpackage.pekit.tomlis 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
mainpackage.
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:
- workspace base
- source base (only when the recipe delegates packages to a materialised source and the source root differs from the recipe root)
- recipe base
- source member(s) with this selector
- 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 asversion,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). --allselects 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):
[]
= "fonts-{{multipack}}"
= "{{version}}"
= "any"
[]
= ["serif", "sans", "mono"]
[]
= "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):
[]
= "locale-{{multipack}}"
= "{{version}}"
= "any"
[]
= "build:usr/share/locale/*"
= '^(?P<value>[a-z]{2})$'
[]
= "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:
= "peipkg"
= ["main"]
[]
= "{{version}}"
= "x86_64"
= "MIT"
= "https://example.org/hello"
packages.pekit/hello.package.pekit.toml — the runtime member:
[]
= "hello"
= "The hello program"
[]
= "usr/bin/hello"
packages.pekit/hello-doc.package.pekit.toml — the docs member:
[]
= "hello-doc"
= "Documentation for hello"
[]
= "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:
| Layer | Source | Naming |
|---|---|---|
| Managed | Set by pekit (recipe/source roots, output paths, version parts, dependency outputs) | PEKIT_* |
| Keyring | Keyring files and --keyring.x.y= literals | PEKIT_KEYRING_* |
| User | [env] in the workspace, recipe, and selected env file | Any 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 withPEKIT_. Doing so is areserved_enverror. - A keyring export that collides with a user
[env]variable or with a managed variable is anenv_collisionerror, 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:
[]
= "clang"
= "-O2 -pipe"
= "/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:
- workspace
[env] - delegated source recipe
[env], then that source's env file (only when the recipe delegates env — see Recipe anatomy) - recipe
[env] - 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:
[]
= "$PEKIT_OUT/bin:$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
= "peipkg"
[]
= "-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
[]
= "firejail --quiet -- {{command}}"
# Array form — {{command}} must be a complete argument (never argv[0])
[]
= ["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 withsh -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):
- workspace
[wrap] - delegated source env file
[wrap](only when the recipe delegates wrap) - recipe
[wrap] - 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:
- 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 amissing_keyringerror. - Otherwise the value is treated as a name: pekit looks for
<value>.keyring.pekit.tomlin 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. - 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_keyringerror.
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
# Path-like → loaded directly
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.
# 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
= "<value>" # -> PEKIT_KEYRING_TOKEN
[]
= "<value>" # -> PEKIT_KEYRING_TCB_PRIV
= "<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 #
| Command | Selects | Versions | --all | Side effects |
|---|---|---|---|---|
build | build targets + their needs | multiple | no | Runs build targets; stages their output under out_dir. |
test | test targets + the builds they need | single resolved | no | Stages needed builds, then runs the selected test targets. |
install | install targets + the builds they need | single resolved | no | Stages needed builds, then runs the selected install targets. |
package | package members + the builds they list | multiple | yes | Stages builds and writes .peipkg artifacts under out_dir. Recipes with a reproducible source also emit a corresponding-source package. |
publish | package members (as package) | multiple | yes | Packages, then publishes to a configured localdir destination. |
clean | one optional clean target | none | no | Runs a clean target and/or removes the managed output directory. |
gen | gen targets | none | yes | Runs gen commands; writes generated source into the tree. |
verify | gen targets | none | yes | Runs gen verify_commands; a read-only drift check (writes nothing). |
lock | the recipe's source | multiple | no | Fetches and pins the selected versions in pekit.lock without building. With no version flags, reports the current lock instead. |
workspace | a 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:
[]
= "make -j$(nproc)"
[]
= "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>]:
[]
= "make lib"
[]
= "make tools"
= ["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:
missing_target: unknown build target "libz"; available targets: lib, tools
With no selector, selection follows these rules for the command's section:
| Situation | Result |
|---|---|
Section has a main target | main is selected. |
No main, command is build, exactly one target exists | that lone target is selected. |
No main, and none of the above | error, listing the available targets. |
| Section is absent / empty | missing_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 throughneedsare gathered and run in dependency order (a target runs after everything it needs). Each build runs at most once per invocation. - For
test,install, andpackage, pekit pulls the build targets named in the selected target'sneeds(forpackage, thebuildslist 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:
| Invocation | Runs clean target | Removes output dir |
|---|---|---|
pekit clean (default) | yes, if clean.main (or the named selector) exists | yes |
pekit clean --output-only | no | yes |
pekit clean --target-only | yes | no |
Details:
- Default. Runs
clean.mainif present (if there is noclean.mainand 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,--envand--keyringare unused and rejected (unsupported_flag), unless--allow-unuseddowngrades them to warnings.--target-only. Runs the clean target only; the output directory is left in place. With noclean.mainand 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.
--allselects 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.
[]
# Runs at the recipe root; writes generated source in place.
= "./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:
= """
./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.
= ["thing-that-embeds-bindings"]
= []
[]
= "*"
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>runscommand— regenerate in place.pekit verify <name>runsverify_command— check only, never writes the tree. This is the on-demand / CI drift gate. Naming a gen target that has noverify_commandis an error. A barepekit verifyselectsgen.main, exactly like the other commands;pekit verify --allruns every gen target that has averify_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_test | Meaning |
|---|---|
| absent | Gate 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-verifyskips all drift gates.pekit build --no-verify=bindings,otherskips only the named gens (an unknown name is an error).- On
package/publish,--no-verifyskips 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 -
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=valueon 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; cleanthat would delete managed output emitsclean_plan("would remove managed output") and removes nothing;package/publishemit 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:
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:
--recipe <path|dir>— if given, this wins. A directory means<dir>/pekit.toml; a file path is used as-is. A missing target ismissing_recipe.- A remote positional — if
--recipewas not given and the first positional argument looks like a remote locator, it is taken as the recipe (and dropped from the selector list). - Walking up — otherwise pekit searches from the current directory upward
for the nearest
pekit.toml, stopping at the filesystem root (not_foundif 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:
- Discovers members from
include/excludeglobs (each member is a directory containing apekit.toml). - Shares configuration —
[env],[wrap], and a distro-wide[policy]— down to every member. - 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
= ["core/*", "extra/**"]
= ["extra/experimental/*"]
[]
= "1700000000"
[]
= ["nice", "-n", "10", "{{command}}"]
[]
= "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:
- Every
excludepattern is expanded and its matches recorded. - Every
includepattern is expanded. A match is kept only if it was not excluded and the matched directory contains apekit.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").
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 Nand--fail-fast— must appear afterworkspaceand 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 beforeworkspaceor 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.
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-fastis set). -
--allow-unusedrelaxes this per member. When a run carries positional selectors (target names, ordef:variantpackage 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-unusedalso suppresses per-member unsupported version selection rather than failing that member. - selectors that a member cannot satisfy are suppressed for that member
(reported via
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:
[]
= "GLIBC_"
= "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_keyerror. Pekit never falls back to silently writing unsigned output — a signing setup that degrades quietly would defeat the point. - Without a key,
packagewrites unsigned artifacts (fine for local development), butpublishrefuses peipkg-format packages withunsigned_publishunless 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:
[]
= "make && make install DESTDIR=$PEKIT_OUT"
[]
= "tcb.priv"
= "tcb.priv"
# dev.keyring.pekit.toml — per-developer, gitignored
[]
= "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:
- Adds a zero-filled
.peios.sigsection, or reuses one the build already reserved (it must beSHT_PROGBITSand exactly 3310 bytes). Adding a section appends to the file and never moves existing bytes, so program headers and loadable segments are unaffected. - 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.
- Verifies the finished file the way the kernel will before returning, and
emits a
signevent 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-unsignedequivalent, 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:
| Field | Value |
|---|---|
timestamp | The run's start time, UTC RFC 3339. |
farm_id | local — pekit builds are local builds. |
source_ref | The source provenance ref: git:<url>@<commit>, url:<url>#sha256:<hash>, and so on. |
recipe_ref | The 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. |
builder | The 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_package | The 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.enabledis not set tofalse(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 committedpekit.lock; for a git source, agit archiveexport 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 fixedpatches/name even when[source].patchespicks a different directory.recipe/— the build-controlling files from the recipe directory:pekit.toml,pekit.lock, package definitions (includingpackages.pekit/), env files, andkeys/.*.keyring.pekit.tomlfiles 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-unsignedis passed, as above. - Unanchored provenance is refused (
unanchored_provenance) unless--allow-unanchoredis passed. Unanchored means a url source with neither achecksumnor 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 plainpackagefrom an unanchored source warns instead. - An existing destination file is refused (
publish_exists) when the target'soverwrite = 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 anunknown_keydiagnostic. 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_typeerror.
pekit.toml #
Worked example #
= "dist"
[]
= "-O2"
= "/usr"
[]
= ["pesb", "run", "--", "{{command}}"]
[]
= "https://github.com/example/tool.git"
= "v{{version}}"
= ">=1.0"
= "^v(?P<version>[0-9.]+)$"
[]
= "make PREFIX=$PREFIX"
[]
= ["make", "docs"]
= ["main"]
[]
= "make link"
= ">=2.38"
[]
= "make check"
[]
= "make install DESTDIR=$PEKIT_OUT"
[]
= "make clean"
Top-level keys #
The recipe file accepts exactly these top-level keys. Anything else is an
unknown recipe key error.
| Key | Type | Required | Meaning |
|---|---|---|---|
out_dir | string | no | Output directory name, relative to the recipe root. Default "out". |
env | table | no | Environment variables exported to every target command. See [env]. |
wrap | table | no | Command wrapper applied to every target. See [wrap]. |
source | table | no | Where the recipe's source tree comes from. See [source]. |
delegate | bool or table | no | Borrow build/env/wrap/package definitions from the source tree. See [delegate]. |
source_package | table | no | Corresponding-source package emission control. See [source_package]. |
build | table | no | Build target(s). See [build] / [test] / [install] / [clean]. |
test | table | no | Test target(s). |
install | table | no | Install target(s). |
clean | table | no | Clean target(s). |
gen | table | no | Source-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).
| Key | Type | Required | Meaning |
|---|---|---|---|
| <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).
| Key | Type | Required | Meaning |
|---|---|---|---|
command | string or array of strings | no | The 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 (element0is 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-table | Meaning |
|---|---|
[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:
| Key | Type | Required | Meaning |
|---|---|---|---|
patches | string | no | Name 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] #
| Key | Type | Required | Meaning |
|---|---|---|---|
url | string | yes | Git remote URL to clone. |
ref | string | no | Ref (tag/branch/commit) to check out. Templated. Default "{{version}}". |
versions | string | no | Version cap: a constraint string filtering enumerated or requested versions (see Versions). |
tag_regex | string | no | Regex extracting version numbers from tag names during enumeration. |
[source.url] #
| Key | Type | Required | Meaning |
|---|---|---|---|
url | string | yes | URL to download. Templated with the version. |
extract | bool | no | Treat the download as an archive and extract it. Default false. |
root | string | no | Sub-directory within the extracted tree to use as the source root. Default ".". |
versions | string | no | Version cap: a constraint string filtering enumerated or requested versions (see Versions). |
file_regex | string | no | Regex extracting version numbers when enumerating from a listing. |
checksum | string or table | no | Expected checksum. A bare string applies to all versions; a table maps version → checksum. |
signature | table | no | Upstream 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
key_files | string array | yes | Committed public key files (armoured or binary OpenPGP), resolved relative to the recipe root. Must be non-empty. |
url | string | no | Signature URL template. {{source_url}} expands to the rendered artifact URL; version variables are also available. Default "{{source_url}}.sig". |
of | string | no | What 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. |
fingerprints | string array | no | Allowlist 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] #
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | Path 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
all | bool | no | Delegate everything (build, env, wrap, packages). |
build | bool | no | Delegate build targets. |
env | bool | no | Delegate [env]. |
wrap | bool | no | Delegate [wrap]. |
packages | bool | no | Delegate 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | no | Package name. Default <recipe-dir>-source. |
enabled | bool | no | Set 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 committedpekit.lock, or agit archiveexport of the locked commit.patches/— the recipe's patch series, when apatches/directory exists.recipe/— the build-controlling files from the recipe directory:pekit.toml, package definitions (includingpackages.pekit/), env files,pekit.lock, andkeys/.*.keyring.pekit.tomlfiles 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]withcommand = "make". This defines a single target namedmain. - 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:
| Key | Type | Required | Meaning |
|---|---|---|---|
command | string or array of strings | yes | The command. String form runs through a shell; array form is an argv executed directly. An empty array is rejected. |
needs | array of strings | no | Names of other targets in the same section that must run first. |
clear_out | bool | no | Wipe this target's output directory before running. Default true. |
dependencies | table | no | Build targets only. Build-time dependencies the target needs provisioned. See below. |
sign | table | no | Build 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:
[]
= ">=2.38"
= "*"
| Level | Type | Meaning |
|---|---|---|
| provider | table | A dependency-provider selector. |
| provider.<name> | string | Version 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:
[]
= "tcb.priv"
= "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.
| Level | Type | Meaning |
|---|---|---|
| kind | table | A 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> | string | A 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:
| Key | Type | Required | Meaning |
|---|---|---|---|
command | string or array of strings | yes | The generator. Runs at the recipe root; writes generated source in place. |
verify_command | string or array of strings | no | The drift gate: exit 0 = in sync, non-zero = stale. Run by pekit verify and by the consuming-command pre-flight. |
verify_on_build | array of strings | no | Build target names this gate applies to. Absent gates every build; [] gates none; a list gates only those. |
verify_on_test | array of strings | no | As verify_on_build, for the test namespace. |
dependencies | table | no | Build-time dependencies for command (and for verify_command, unless overridden). Same shape as a build target's dependencies. |
verify_dependencies | table | no | When 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 #
= "peipkg"
= ["main"]
[]
= "tool"
= "{{version}}"
= "x86-64-v3"
= "An example tool"
= "MIT"
= "https://example.com/tool"
= "opt.tool"
[]
= ">=2.38"
= { = ">=1.3", = "opt.tool" }
[]
= ">=8.0"
[]
= "{{version}}"
[]
= "usr/bin/tool"
= { = "usr/share/tool/", = true }
[]
= "tool"
= ["@recipe:share/*.tmp"]
[]
= "usr/bin/editor"
= "usr/bin/tool"
[[]]
= "dist"
= false
Top-level keys #
The package file accepts exactly these top-level keys; anything else is an
unknown package key error.
| Key | Type | Required | Meaning |
|---|---|---|---|
format | string | no | Artifact format: "tar" or "peipkg". Default "tar". |
clear_out | bool | no | Wipe the package stage directory before building. Default true. |
builds | array of strings | no | Names of build targets this package requires before staging. |
package | table | no | Package metadata. See [package]. |
files | table | no | Payload file mappings. See [files]. |
symlinks | table | no | Symlinks to embed in the payload. See [symlinks]. |
excludes | array of strings | no | Refs/globs to drop from directory payloads. See excludes. |
multipack | table | no | Expand into a family of instances. See [multipack]. |
publish | table | no | Where to copy finished artifacts. See [publish]. |
dependencies | table | no | Runtime dependencies. See [dependencies]. |
optional_dependencies | table | no | Optional runtime dependencies (same schema as dependencies). |
conflicts | table | no | name → constraint of packages this one conflicts with. |
provides | table | no | name → version of capabilities this package provides. |
replaces | table | no | name → constraint of packages this one replaces. |
side_effects | array of strings | no | Declared install-time side effects. |
sd_overrides | table | no | path → SDDL security-descriptor overrides. |
claims | table | no | Claim slots on provides/dependencies. See [claims]. |
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.
| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | no | Package name. Defaults to the package selector (suffixed with the multipack value for multipack instances) when omitted. |
version | string | no | Package version. Required for peipkg. |
architecture | string | no | Target architecture. Required for peipkg. |
description | string | no | Human-readable description. |
license | string | no | License identifier. Required for peipkg. |
homepage | string | no | Project homepage URL. |
default_root | string | no | Preferred 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_package | boolean | no | Declares 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.
| Form | Type | Meaning |
|---|---|---|
<name> = "constraint" | string | Version constraint; the dependency lands in the depender's own root. |
<name> = { constraint, root } | table | Table form (below). |
Table form keys — only these two are accepted (unknown_key otherwise):
| Key | Type | Required | Meaning |
|---|---|---|---|
constraint | string | no | Version constraint. Omitting it matches any version. |
root | string | no | Named 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 form | Meaning |
|---|---|
"ref" = "dest/path" | Copy the ref to dest/path. |
"ref" = { path, override } | Table form (below). |
Table form keys — only these are accepted:
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | Destination path in the payload. Must be non-empty. |
override | bool | no | Mark 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 form | Meaning |
|---|---|
"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:
| Key | Type | Required | Meaning |
|---|---|---|---|
target | string | yes | Symlink target text. Must be non-empty. |
override | bool | no | Mark 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:
[]
= ["gtk3", "gtk4"]
Each value must be a valid selector, non-empty, and unique.
Enumerated from files:
[]
= "@source:themes/*/theme.ini"
= "^(?P<value>.+)$"
[multipack.enum.files] accepts exactly path and regex, both required:
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | A ref glob (templated) whose matches are enumerated. |
regex | string | yes | Regex applied to each match's basename. Must have exactly one capture group, or a single named value group, which supplies the instance value. |
[publish] #
Where finished artifacts are copied. The only accepted target is localdir,
written as an array of tables:
[[]]
= "dist"
= false
Each entry accepts only path and overwrite:
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | Destination directory, relative to the workspace root (or recipe root outside a workspace). Templated. The artifact keeps its own filename. |
overwrite | bool | no | Allow 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.
[<name>.<slot>]
= "usr/bin/editor"
= "usr/bin/tool"
[<name>.<slot>]
= "usr/bin/editor"
A slot descriptor accepts exactly these two keys (anything else is rejected); both are strings and both are templated:
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | no | The symlink location (set on a consumer, or as a provider default). |
target | string | no | The 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
include | array of strings | yes | Member patterns the workspace builds. Must be present and non-empty, or the load fails (missing_key). |
exclude | array of strings | no | Patterns removed from the include set. |
[env] | table | no | Workspace-level environment variables. See env table. |
[wrap] | table | no | Workspace-level command wrapper. See wrap table. |
[policy] | table | no | Distro-wide derivation policy. See below. |
[policy] #
The [policy] table currently carries exactly one sub-table. Any other key
under [policy] is rejected.
| Key | Type | Required | Meaning |
|---|---|---|---|
[policy.symbol_versions] | table (string → string) | no | Maps 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_":
[]
= "GLIBC_"
= "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).
| Key | Type | Required | Meaning |
|---|---|---|---|
[env] | table | one of the three | Environment variables. See env table. |
[wrap] | table | one of the three | Command wrapper. See wrap table. |
dependency_provider | string | one of the three | Selector 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 value | File loaded | Must exist? |
|---|---|---|
(unset) or main | env.pekit.toml | no (silently absent) |
none | (no env file loaded) | n/a |
any other <name> | <name>.env.pekit.toml | yes — 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.
| Rule | Detail |
|---|---|
| Value type | Every value must be a string. |
| Name grammar | Names must match ^[A-Za-z_][A-Za-z0-9_]*$. |
| Reserved prefix | User env may not set any PEKIT_* name — those are pekit-managed. A PEKIT_-prefixed name is rejected at build time (reserved_env). |
| Layering | Applied workspace → (delegated source) → recipe → env file, later layers overriding earlier ones. |
| Expansion | Values 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. |
[]
= "clang"
= "-O2 -pipe"
= "$CFLAGS -Wp,-D_GLIBCXX_ASSERTIONS"
= "$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.).
| Key | Type | Required | Meaning |
|---|---|---|---|
command | string or array of strings | no | The 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 element0(the program). An element that merely contains{{command}}inside other text is rejected.
[]
= ["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 path | Exported variable |
|---|---|
token | PEKIT_KEYRING_TOKEN |
github.token | PEKIT_KEYRING_GITHUB_TOKEN |
registry.api-key | PEKIT_KEYRING_REGISTRY_API_KEY |
# placeholders only — never commit real secrets
= "REPLACE_ME"
[]
= "REPLACE_ME"
[]
= "REPLACE_ME"
Restrictions #
| Rule | Detail |
|---|---|
| Leaf type | Every leaf must be a string. A non-string leaf is an error. |
| Nesting | Sub-tables nest arbitrarily; each nesting level adds a segment to the exported name. |
| Typed entries | A table containing a path or content key is a typed entry and is not supported yet (unsupported_keyring_entry). |
| Collisions | A 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 path | Meaning |
|---|---|
signing.package_key | Path 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 #
| Key | Type | Meaning |
|---|---|---|
schema | integer | Lockfile schema version. Currently 1. |
[[source]] | array of tables | One entry per locked version, kept sorted by version. |
Each [[source]] entry carries the keys for its source kind:
| Key | Set for | Meaning |
|---|---|---|
version | all | The locked version. Empty for a versionless url fetch. |
url | url sources | The rendered URL fetched at lock time. Provenance only — the hash, not the address, is the assertion. |
sha256 | url sources | SHA-256 of the fetched artifact. |
ref | git sources | The rendered ref the version resolved through. |
commit | git sources | The commit the ref resolved to — the assertion. |
signature_key | url sources with [source.url.signature] | Hex fingerprint of the pinned upstream key that verified the artifact at lock time. |
locked_at | all | UTC timestamp of the pinning run (RFC 3339). |
Worked example #
= 1
[[]]
= "0.5.12"
= "https://example.org/dash-0.5.12.tar.gz"
= "6a474ac46e8b0b32916c4c60df694c82058d3297d8b385b74508030ca4a8f28a"
= "9f0b7ddc1325a3e2c40e5f0a88b1c62f3d94ab07"
= "2026-08-10T11:00:00Z"
[[]]
= "1.2.0"
= "v1.2.0"
= "8f3a1bc99e2f6d41c07b21ac1886d63b7f2e5d10"
= "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:
| Condition | Error |
|---|---|
series missing or unreadable | patch_series |
| An entry that repeats, contains whitespace, or escapes the directory | patch_series |
| A series entry with no file behind it | patch_missing |
A *.patch file in the directory the series does not list | unused_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.
| Variable | Source | Availability |
|---|---|---|
{{version}} | the selected version, verbatim (Raw) | Errors when no version is selected. |
{{major}} | major component | Available once a version is selected. |
{{minor}} | minor component | Errors if the selected version has no minor component. |
{{patch}} | patch component | Errors 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 value | Only 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 variable | Environment 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 form | Root | Meaning |
|---|---|---|
target:path | the build target's output stage | Reads from the output of build target target. |
:path | build target main's output stage | Same as above with the target name defaulted to main. |
@source:path | the materialised source tree (LiteralRoot) | Reads from the checked-out / extracted source. |
@recipe:path | the recipe directory | Reads a file shipped next to the recipe. |
@workspace:path | the workspace root | Reads a file from the workspace. Errors if used outside a workspace. |
bare path | owner default | A 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:
[[]]
= "dist/{{version}}"
= false
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | Destination directory, relative to the base root. Templated with {{version}} and {{multipack}}. The artifact keeps its own filename. |
overwrite | bool | no (default true) | Whether an existing destination file may be replaced. When false, an existing destination is an error (publish_exists). |
Resolution details:
| Aspect | Behaviour |
|---|---|
| Base root | The workspace root when building in a workspace, otherwise the recipe root. path is joined onto it. |
| Final destination | <base>/<path>/<artifact-filename>. |
| Collisions | Two 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.tomland package-file schemas. - Environments and keyrings — how env and keyring files are selected and layered.
- Signing and provenance —
signing.package_keyandpekit.lockin 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.
| Shape | How a value is written |
|---|---|
| No-value | Never 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-value | Value 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.
| Flag | Value form | Semantics |
|---|---|---|
--recipe <path|dir> | Required-value | Use this recipe instead of searching upward from the cwd. A directory means <dir>/pekit.toml. |
--workspace <path|dir> | Required-value | Use this workspace file/root. Valid only with the workspace command. |
--allow-unused | No-value | Downgrade "command does not support this recognised flag" from a fatal error to a suppressed notice. |
--dry-run | No-value | Run the full planning pipeline but stop before any side effect; emit plan events instead. |
--quiet | No-value | Human renderer, routine progress suppressed (only warning, artifact, publish, workspace_summary, lock, sign events). |
--verbose | No-value | Human renderer plus extra staging/progress events. |
--json | No-value | Emit 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.
| Flag | Value form | Meaning |
|---|---|---|
--version <v>, -V <v> | Required-value | Select a single recipe version. -V is an exact alias. |
--latest | No-value | Select only the newest available version. |
--all-versions | No-value | Act on every available version. |
--local[=<path>] | Optional-value | Use a local source tree instead of resolving source; bare form uses the default, =<path> overrides it. |
--prefer-local[=<path>] | Optional-value | Use the local source when present, otherwise resolve normally. |
--no-build[=<list>] | Optional-value | Reuse 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-value | Skip 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-value | Select 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-value | Load 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, = required | Set a single keyring value inline. Requires a non-empty dotted path and the =; otherwise invalid_keyring_flag. Repeatable. |
--refresh-source | No-value | Force source re-materialisation, ignoring any source cache. The lockfile is still enforced against the fresh download. |
--allow-unanchored | No-value | Permit publish from source with unanchored provenance — no checksum and no lock entry (otherwise unanchored_provenance). |
--allow-unsigned | No-value | Permit publish of peipkg-format packages without a configured signing key (otherwise unsigned_publish). |
--repin | No-value | lock only: re-download the selected version and replace its lock entry — the explicit accept-changed-upstream-bytes ceremony. Requires an exact single --version. |
--all | No-value | package/publish: act on every package definition instead of a selector. gen/verify: act on every gen target. |
--output-only | No-value | clean: remove managed output only, without running a clean target. |
--target-only | No-value | clean: 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.
| Flag | Value form | Meaning |
|---|---|---|
--jobs <N>, --jobs=<N> | Required-value, positive integer | Maximum concurrent members. Default 1. A non-integer or value <= 0 errors with invalid_flag_value. |
--fail-fast | No-value | Abort 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.
| Command | version selection | local source | --no-build | --no-verify | --env | --keyring | --refresh-source | --allow-unanchored | --allow-unsigned | --all | clean mode | --repin |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
build | Yes | Yes | Yes | Yes | Yes | Yes | Yes | |||||
test | Yes | Yes | Yes | Yes | Yes | Yes | Yes | |||||
install | Yes | Yes | Yes | Yes | Yes | Yes | Yes | |||||
package | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | ||||
publish | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | ||
clean | Yes | Yes | Yes | |||||||||
gen | Yes | Yes | Yes | |||||||||
verify | Yes | Yes | Yes | |||||||||
lock | Yes | Yes | Yes |
"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 condition | Diagnostic |
|---|---|
--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 word | missing_command |
More than one of --version / --latest / --all-versions | mutually exclusive |
--local with --prefer-local | mutually exclusive |
--output-only with --target-only | mutually exclusive |
| A recognised flag the effective command does not support | unsupported_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 selector | invalid_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 selector | invalid_selector (lock does not accept selectors) |
lock --repin without an exact single --version | invalid_flags (--repin requires exactly one exact --version) |
--no-verify naming an unknown gen target | missing_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 #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Any 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.
| Variable | Value |
|---|---|
PEKIT_RECIPE_ROOT | Directory containing the resolved recipe (pekit.toml). |
PEKIT_SOURCE_ROOT | Root of the materialised source tree. Equals the recipe root for a non-delegated recipe. |
PEKIT_LITERAL_ROOT | Root used to resolve @source: payload references. |
PEKIT_ROOT | Per-source pekit work base (staging lives beneath it). |
PEKIT_OUT_BASE | Base directory for this source's managed output. |
PEKIT_OUT | This target's own stage directory (where it should write). |
PEKIT_COMMAND | The command kind running the target (build, test, install, clean, gen, verify). |
PEKIT_TARGET | The target's name. |
PEKIT_BUILD_TIMESTAMP | Run start time, Unix seconds. |
PEKIT_SOURCE_TIMESTAMP | Source provenance time, Unix seconds. |
PEKIT_WORKSPACE_ROOT | Workspace root, or an empty string when not in a workspace (always set). |
PEKIT_DEPENDENCIES_FILE | Path to the JSON dependency payload pekit writes for this target. |
PEKIT_DEPENDENCY_PROVIDER | Selected env dependency provider (empty when none). |
PEKIT_DEPENDENCIES | Resolved 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.
| Variable | Value |
|---|---|
PEKIT_VERSION | Full version string. |
PEKIT_VERSION_MAJOR | Major component. |
PEKIT_VERSION_MINOR | Minor component. |
PEKIT_VERSION_PATCH | Patch component. |
PEKIT_VERSION_PRERELEASE | Pre-release component (may be empty). |
PEKIT_VERSION_BUILDMETA | Build-metadata component (may be empty). |
Per-dependency and per-keyring variables #
| Variable pattern | When set | Value |
|---|---|---|
PEKIT_<NEED>_OUT | One per entry in the target's needs | Stage 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.