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, run gated tests, write .peipkg package artifacts, then lint them. |
publish | Package as above, 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.pekit helplists the commands andpekit help <command>a command's flags; these docs are the full 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"
= '^(?P<version>\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 it is an absolute path below the recipe root. Defaults to"out". Pekit rejects the recipe root, its parents, and paths outside it: this is pekit's managed output andpekit cleanremoves it wholesale. 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],[source.pypi], 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 — one of
[source.git],[source.url], or[source.pypi], never more than one; - optionally a local override —
[source.local], on its own or alongside a reproducible source.
A source is reproducible when it is git, url, or PyPI — those pin to exact
bytes. Declaring multiple reproducible tables is an error (mixed_source:
"exactly one reproducible source table is allowed").
When writing URL listing filters, remember that file_regex searches the
downloaded listing text. Anchoring it with ^ and $ to a bare filename will
not match that filename inside an HTML page. Use a named version capture
when the package name itself contains digits, for example
m4-(?P<version>[0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz, so those digits cannot
be mistaken for the release version.
The four source kinds #
[source.git] #
Clones a git repository and checks out a ref. It takes five fields —
a required url (passed to git clone --mirror), a ref to check out
(templated with the selected version; defaults to
{{version}} and must render non-empty), a versions cap that filters
enumerated or requested versions, and a tag_regex used when enumerating tags
(see Enumeration). The fifth, tracked_path, selects the
moving-ref snapshot mode described below. An optional [source.git.signature]
table verifies the upstream's release signatures; see
Signed Git releases. The field-by-field schema is in the
recipe format reference.
[]
= "https://github.com/example/widget.git"
= "v{{version}}"
= ">=2.0.0"
= "^v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)$"
If ref renders empty (for example, a bare {{version}} with no version
selected) pekit fails with missing_version — pass --version or set a
non-templated ref.
Signed Git releases #
Many upstreams sign their release tags. A [source.git.signature] table pins
the signing maintainer's OpenPGP key, and pekit then requires a valid
signature for every version it resolves:
[]
= "https://git.kernel.org/pub/scm/libs/libtrace/libtraceevent.git"
= "libtraceevent-{{version}}"
= '^libtraceevent-(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$'
[]
= ["keys/steven-rostedt.asc"]
= ["5ED9A48FC54C0A22D1D0804CEBC26CDB5A56DE73"]
By default the rendered ref must be an annotated tag with an OpenPGP
signature. The tag object must name that same tag and point straight at the
commit the ref resolved to, so a signed tag from another release can't vouch
for this one. For an upstream that signs commits rather than tags, set
object = "commit": the resolved commit must then carry the signature.
Verification is in-process, like URL signatures: no host gpg, no keyring, no
Git signing configuration. It runs after the mirror fetch, which only stores
objects, and before the lock is written or the tree is checked out. A
lightweight tag, an unsigned tag or commit, or a signature pekit cannot read is
signature_missing (or signature_unsupported for SSH and X.509 signatures).
A bad signature is signature_invalid, and a valid signature from a key outside
fingerprints is signature_untrusted_key. Any of these stops the run with
nothing locked. Key expiry works the same way as for
URL signatures, including ignore_expiry.
Unlike a URL signature, a Git signature is checked on every resolve, not
only on the first. The signer's fingerprint goes into the version's lock entry,
and a later resolve that finds the release signed by a different pinned key
stops with lock_mismatch.
[source.git.signature] cannot be combined with tracked_path: a tracked
snapshot follows a branch, not a signed release.
An upstream that signs nothing — lightweight tags, no signed commits — cannot
use this table. The source.signature.required lint
rule reports it, and the recipe records why the upstream can't be verified as a
reasoned allowance in lint.pekit.toml. The exception is then a deliberate
declaration, not a silent default.
Tracking one file on a moving ref #
Some upstream data has no release tags of its own: one file on a stable branch
is the published stream. Set tracked_path to follow that file without
turning every unrelated branch commit into a package release:
[]
= "https://github.com/example/upstream.git"
= "refs/heads/release"
= "security/trust/certdata.txt"
= ">= 2026.01.01"
This is deliberately a bounded mode:
refmust be fixed and non-templated;tag_regexcannot be combined withtracked_path;tracked_pathis one clean repository-relative regular file, not a directory, symlink, or submodule;- discovery observes the fixed ref and compares the selected blob's SHA-256 with the newest matching lock entry. An unrelated commit, or any observation with the same bytes as that newest lock, creates no version;
- a changed blob receives the UTC discovery date
YYYY.MM.DD. Further changed blobs discovered and locked that day receive.2,.3, and so on. The suffix follows committed lock history, and a backwards wall clock clamps to the newest locked date, so the sequence remains monotonic; - the lock binds repository URL, fixed ref, path, immutable commit, Git blob
object ID, and SHA-256 of the blob bytes. It is append-only for this mode:
discover a change with
--latest;--repinis rejected.
--latest, constraints, and --all-versions check the moving ref. The
enumerated set is all matching historical lock entries plus the current
changed blob, if there is one; --all-versions therefore retains history
rather than replacing it with the tip. An exact locked version does not
resolve or fetch the moving ref. It uses the pinned commit and path, works
offline when those objects are cached, and may fetch only the pinned commit on
a cold cache. An exact version not yet locked is accepted only when it is the
date version Pekit computes for the currently observed changed blob.
The source root preserves the tracked relative path and contains no other
upstream file. Targets therefore read the example above at
$PEKIT_SOURCE_ROOT/security/trust/certdata.txt. This mode is suited to a
single independently versioned data input; use ordinary git mode when the
build needs a repository tree.
[source.url] #
Downloads a file over HTTP(S), optionally extracting it as an archive. The
required url is templated with the selected version; extract (default
false) treats the download as an archive, with root naming the subdirectory
inside the archive to promote as the source tree; versions is a version
cap (as for git); listing_url can name a release page when the download
template does not live in a browsable directory; file_regex is used when
enumerating that listing. Like Git tag_regex, named version or component
captures can map upstream filenames into the catalogue's version form; the URL
and archive root then render from that mapped version. checksum pins the download to an expected
digest — a single string or a per-version table (see below). The full schema is in the
recipe format reference.
[]
= "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.
A signing key that expires later does not invalidate an immutable historical
release: Pekit accepts the signature only when its signed creation time falls
within the key's validity period. A present-time key revocation, an explicitly
expired signature, a signature created after key expiry, or a materially
future-dated signature remains a hard failure by default. For an upstream that
knowingly continues to sign new releases after its key expires, a recipe may
set ignore_expiry = true in the signature table. This is an explicit
per-source exception to key expiry only: signature validity, the pinned
fingerprint, revocation, explicit signature expiry, and timestamp checks remain
mandatory.
Signed checksum manifests #
Some upstreams sign a checksum file rather than the artifact: a clear-signed
text file listing each release file's digest. of = "checksums" verifies such a
manifest instead of a detached signature. Dash is the case in the catalogue:
[]
= "checksums"
= "{{source_url}}.sha512sum"
= ["keys/herbert-xu.asc"]
= ["9F9D45FE50AE361530983792C7271D0A49B18BA7"]
Pekit fetches the manifest from url, verifies its clear signature against the
pinned keys, and then requires exactly one entry whose name is the artifact
URL's file name, character for character (./dash-0.5.13.5.tar.gz or a
directory-qualified name does not match). The artifact's digest must equal that
entry's, and its size too when the entry states one. The signer is recorded as
signature_key in the lock, as for a detached signature, beside the artifact's
sha256.
Because one manifest signature vouches for every file the manifest lists, this mode is stricter than a detached signature:
urlis required, since checksum manifests have no conventional name, andfingerprintsis required, each a full key fingerprint.- The manifest must be a clear-signed message and nothing else; text outside the signed block is refused.
- Three line forms are read:
<digest> <name>(coreutils, with*<name>for binary mode),<digest> <size> <name>(Dash's), andSHA512 (<name>) = <digest>(BSD). Digests must be SHA-256 or SHA-512. Any line pekit cannot read, including an MD5 or SHA-1 digest, makes the whole manifest unusable rather than being skipped.
A manifest that fails any of these checks is checksum_manifest_invalid; an
artifact that does not match its entry is checksum_mismatch. As with every
signature failure, nothing is locked or built.
Some upstreams publish one major.minor archive and then maintain it as an
incremental numbered patch series. [source.url.patch_series] models that
scheme without fetching anything from a build script:
[]
= "https://ftp.example.org/widget/widget-{{version}}.tar.gz"
= true
= "widget-{{version}}"
= ">= 5.3.0"
= 'widget-[0-9]+\.[0-9]+\.tar\.gz'
[]
= "https://ftp.example.org/widget/widget-{{major}}.{{minor}}-patches/widget{{major}}{{minor}}-{{patch}}"
= 3
= 0
For a selected 5.3.15, the primary url, root, checksum lookup, and base
signature render with base version 5.3; patch URLs render from 001 through
015. The resulting version is the base archive plus that ordered prefix.
Enumeration exposes 5.3.0, 5.3.1, …, 5.3.15, so ordinary runs select the
latest while --all-versions can reproduce the complete patchlevel history.
The patch listing must be contiguous from 1; a missing number is
url_patch_gap. A missing patch directory represents a new base release at
patchlevel zero.
[source.url.patch_series.signature] accepts the same fields and has the same
required-verification semantics as [source.url.signature]. Each patch is
downloaded, verified, and recorded in the selected version's lock entry before
that entry is written, so failure anywhere in the chain leaves no partial
lock. file_regex is optional; when present it must contain a named patch
capture. patch_width controls zero-padding and strip is the non-negative
path-component count passed to patch -p (both default to zero).
[source.pypi] #
Selects a project source distribution from PyPI's standardized JSON Simple API. This is the low-maintenance choice for recipes that should automatically discover new PyPI releases:
[]
= "asciidoc"
= "sdist"
= ">= 10.2.1"
All three fields are declarative rather than URL templates. project is the
PyPI project name; artifact is required and currently must be exactly
"sdist"; and versions is the usual optional source version cap. Pekit
normalizes the project name for the /simple/<project>/ request, asks for the
versioned JSON representation, and considers only
standardized .tar.gz sdists.
Automatic discovery deliberately excludes yanked files, prereleases, wheels,
legacy non-standard sdist filenames, and versions outside Pekit's version
grammar. A release must expose exactly one eligible sdist with a valid SHA-256:
none is pypi_sdist_missing when requested exactly, while multiple candidates
are pypi_sdist_ambiguous. Pekit never guesses between files.
The selected file's index-advertised SHA-256 is verified on download, and its
exact file URL and digest are written through the ordinary
pekit.lock mechanism. Once locked, an exact-version rebuild
does not need the live project index: it replays the pinned URL and hash from
the lock (using the artifact cache when present). --latest, constraints, and
--all-versions still consult the index so they can discover newly published
versions. A release that is yanked after it was locked therefore remains
rebuildable by exact version but is not selected during a fresh automatic
discovery.
[source.local] #
Points at a directory already on disk. Its single field is a required path —
the directory to use as the source tree, resolved relative to the recipe
directory.
[]
= "../widget-checkout"
[source.local] is an override, not a reproducible source: a recipe
with only [source.local] cannot be pinned or enumerated. The local tree is
only used when a local flag selects it — see below.
Additional inputs #
A recipe has one source. When a build genuinely consumes a second upstream,
declare it as an input: [input.<name>]. The Peios kernel is the case this
exists for — it is built from the pkm tree and the pristine Linux tarball, and
neither is the other's patch series.
An input is a [source.url] addressed by a name. It takes the same fields,
including a signature block, and pekit puts it through the same download,
the same signature verification and the same lockfile pinning.
[]
= "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{{version}}.tar.xz"
= "https://cdn.kernel.org/pub/linux/kernel/v7.x/"
= 'linux-[0-9]+\.[0-9]+(?:\.[0-9]+)?\.tar\.xz'
= "= 7.0.9"
= true
= "linux-{{version}}"
[]
= "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{{version}}.tar.sign"
= "decompressed"
= ["keys/greg-kroah-hartman.asc"]
= ["647F28654894E3BD457199BE38DBBDC86092693E"]
Three things differ from a source:
- An input carries its own version.
{{version}}inside an input block is that input's version, never the recipe's. The two move independently. - An input is pinned, not discovered. Its
versionsmust name exactly one version —"= 7.0.9", or a bare"7.0.9". A range is rejected (invalid_versions), because an input is never swept by--latestor--all-versions. Moving one is a deliberate edit. - An input takes no patch series. Patches apply to the source tree.
Targets read the materialised input at $PEKIT_INPUT_<NAME>, upper-cased
with hyphens becoming underscores, so [input.linux] is $PEKIT_INPUT_LINUX.
With extract = true it is the extracted tree, with root naming the directory
to promote; otherwise it is a directory holding the downloaded file. Under
isolation each input is bind-mounted read-only, so a target reads exactly the
verified bytes and cannot alter them.
[]
= 'cp -a "$PEKIT_INPUT_LINUX/." "$PEKIT_OUT/"'
Because pekit fetches and verifies inputs itself, before any build root exists, a build needs no network access and no PGP implementation of its own — which is what lets the same recipe work identically on a native rung and on a Debian reference rung, where only Debian packages are installed.
Each input's pristine archive is carried in the recipe's
source package under upstream/<name>/, and its hash is in
the lockfile, so a recipient can verify those bytes independently.
Inputs in a delegated source #
A source tree that carries its own pekit.toml can declare inputs there, next
to the targets that read them. When a recipe delegates its build to that tree
(delegate = true, or build = true in a [delegate] table), the source's
inputs come with the borrowed targets. The Peios kernel works this way: pkm
declares [input.linux] with kernel.org's signing key committed beside it, so
the Linux release it is derived from moves in the same commit as its patch
series, and the catalogue recipe that builds it stays a pointer.
- The delegating recipe does the fetching and pinning. Pins go in its own
pekit.lock, the one record of everything the build consumed. The source tree is never written to. - Keys resolve where they were declared. A relative
key_filespath names a file beside the source'spekit.toml, and lint reports the source's inputs against that file. - A recipe input overrides by name. If the delegating recipe declares an input with the same name, it replaces the source's input as a whole, the same rule that governs delegated targets.
Because each release of the source can name its own input version, the lock keys input pins by name and version. Building an older release verifies against the pin that release recorded.
Selecting the local override #
Two invocation flags choose the local override; they resolve differently and are mutually exclusive (passing both is an error).
--local(strict): require a local source. If neither[source.local]nor--local=<path>supplies a directory, pekit fails 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.
Tracked-path git (git). Pekit keeps a separate bare cache under
<out_dir>/_source_cache/git-tracked/<hash>/repo.git. Discovery shallow-fetches
the fixed ref with a blob:none partial-clone filter, resolves its commit, and
requests only tracked_path's blob. A server without partial-clone filtering
may transfer the other blobs in that shallow snapshot, but Pekit still
materialises and packages only the selected regular file. A locked resolve
uses the lock's commit directly and never consults the moving ref; a cold cache
therefore requires a server that permits fetching that pinned commit object.
The materialised tree is recreated from the verified blob on every resolve,
then any recipe patch series is applied. Provenance is
git:<url>@<commit>:<path>#sha256:<blob-sha256>.
URL (url). pekit caches the downloaded artifact under
<out_dir>/_source_cache/url/<hash>/:
- 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. - For a remote patch series, download patches 1 through the selected patchlevel. Verify the base and every patch against the version's lockfile entry, cache hits included — or atomically create the entry, verifying all configured upstream signatures first.
- 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 remote patches in numeric order with
patch, no fuzz, then apply the recipe's patch series, when one is declared. The series' content hash joins the materialisation scope, so an edited patch lands in a fresh extraction. - Write a
source.pekit.jsonmanifest.
The downloaded artifact is cached regardless. The materialised tree is reused, including for checksum-pinned sources, while its manifest's source identity matches: source kind, rendered URL, checksum, extraction settings, root, provenance and patches. Checksum and lock verification still run on every resolve. An identity change invalidates the materialised scope and its staged outputs.
The source timestamp is metadata, not part of that identity. Tar downloads
use their newest member's timestamp; anchored downloads without an archive
date fall back to the recipe directory's last commit time. A commit changing
that fallback date refreshes source.pekit.json without replacing or retiming
source files or discarding completed builds. Thus --no-build can reuse a
staged build across a notes or lockfile commit. New target executions receive
the current timestamp in their environment; reused build outputs are not
rebuilt just to change it. Package provenance checks still apply separately.
PyPI (pypi). Pekit first selects the one eligible sdist for the chosen
version as described above, then uses the URL materialisation path: the
advertised hash is verified, the pristine .tar.gz is cached, its standardized
top-level directory is extracted, patches are applied, and the exact file is
locked. Provenance is pypi:<normalized-project>@<version>#sha256:<hash>.
Caching and --refresh-source #
Pekit does not expose configurable cache policies. Caching is exactly the
behaviour above — a persistent raw cache (mirror repo / downloaded artifact)
plus a materialised tree — and the one knob is --refresh-source, which
discards the cache and rebuilds from scratch:
- git: removes the mirror repo and the checkout scope, forcing a fresh
clone --mirrorand checkout. Tracked-path git removes its sparse cache and refetches the selected immutable commit (for an exact lock) or moving ref (during discovery), without changing what the lock asserts. - url / PyPI: removes the cached artifact and the materialised scope, forcing a re-download and re-extract.
- local / sourceless: nothing to refresh.
A refresh does not relax the lock: the fresh download is still verified
against the lockfile, so --refresh-source is how you check upstream and
pekit lock --repin is how you accept a change.
The lockfile #
Fetched inputs are pinned trust-on-first-use in a machine-written
pekit.lock beside pekit.toml. The first time a source resolves for a
version, pekit records what it fetched — the artifact's SHA-256 for a url or
PyPI source, every ordered remote-patch URL and SHA-256 when present, or the
resolved commit for an ordinary git source, with the verified signer's
fingerprint when the recipe has a [source.git.signature]. A tracked-path git entry records
the repository, fixed ref, path, commit, blob object ID, and blob SHA-256. Every
later resolve verifies against
that entry instead, cache hits included. A mismatch is a hard
lock_mismatch stop: upstream's published bytes (or a tag) changed under a
version that was already pinned. Accepting such a change is an explicit
ceremony, never automatic for ordinary git and URL sources:
pekit lock --repin --version 1.2.0
Additional inputs are pinned in the same file, in their
own [[input]] entries keyed by name and version:
[[]]
= "linux"
= "7.0.9"
= "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.0.9.tar.xz"
= "ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4"
= "647f28654894e3bd457199be38dbbdc86092693e"
An input asserts the same thing a url source does — these bytes, verified by
this key — and stops the same way when they change. Moving an input to a new
version is an edit to its versions constraint. The new version is verified and
pinned the first time it resolves, like a new version of a source, and the old
pin stays.
Commit the lockfile with the recipe. Local sources, dry runs, and git sources
without a selected version (a bare branch ref is a deliberately moving target)
are never locked. The file's exact schema is in
Supporting files; the lock
command — including pre-locking versions without building — is in the
command-line reference.
Source packages #
A recipe with a reproducible source automatically emits a
corresponding-source package alongside its peipkg-format members — the
pristine upstream artifact, the patch series, and the recipe files, verifiable
against the committed pekit.lock — and every emitted manifest records the
provenance of its inputs, recipe, and builder. Both are covered in
Signing and provenance; opt out or
rename with the recipe's
[source_package] table.
Corresponding-source bundles preserve relative dangling symlinks used by upstream
test fixtures, provided every component remains inside the exported tree. Both
publication and rebuild.py reject absolute targets, escaping traversal, cycles
and non-directory parents. A missing link target is not a missing declared input:
all declared files and the original link text still undergo identity checks.
Compact prepared-source archives apply the same checks within source/.
Archive extraction #
For an extracted URL source, exclude optionally names files or directory
subtrees to omit, relative to the selected root:
[]
= "https://download.qemu.org/qemu-{{version}}.tar.xz"
= true
= "qemu-{{version}}"
= ["roms/edk2"]
Each entry is an exact, canonical relative path, without a trailing slash,
.. components, backslashes, glob patterns or templates. roms/edk2 omits
that entry and its descendants, but keeps roms/edk2-other. The root itself
cannot be excluded, and a nonempty list requires extract = true. A path
absent from the archive is harmless. The same option is available for
[input.<name>] archives.
Exclusions are applied before creating archive entries: an omitted subtree may contain an escaping symlink, but the symlink is never created. Every member name still undergoes the extraction-root containment check, including omitted members. All normal safety checks continue to apply to retained entries. Exclusion does not dereference links or permit otherwise unsafe retained entries.
The exclusion list participates in the extracted-source and input cache
identity; changing it cannot reuse a tree extracted with a different list.
Downloads, checksum/signature verification and lockfile hashes still cover
the complete upstream artifact. Corresponding-source packages retain that
pristine artifact, including excluded paths. Recipe patches apply to the
filtered source tree through the usual [source] patches mechanism.
Extraction is chosen by the artifact's filename suffix. Only these formats
are handled; anything else is an unsupported_archive error:
| 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 and PyPI sources materialise once per patch-set content: the series' hash joins the materialisation scope, so an edited patch extracts and patches a fresh tree.
- local sources are never patched — a local tree is your own working state, often with the series already applied or mid-rework. pekit emits a warning and continues.
A failed hunk is patch_apply; a patch that matches nothing is
patch_skipped; either aborts materialisation and leaves no half-patched
tree behind. The series grammar and its validation errors are in
Supporting files.
Patches are committed recipe content, so the lockfile is
uninvolved: it pins fetched bytes, and the patch series is already under
version control. The whole directory ships in the recipe's
source package under patches/ — the shipped series is
the applied series by construction.
This recipe-owned series is distinct from
[source.url.patch_series], whose patches are upstream inputs:
remote patches are signature-verified and locked, ship below
upstream/patches/, and are applied before any recipe-owned patches.
Rebasing on a version bump is deliberately manual: a stale patch fails
loudly, so materialise the new tree (pekit lock --version <v> is enough),
fix the patch, and run again.
Enumeration #
Reproducible sources can list the versions they offer upstream; this feeds
--latest, --all-versions, and constraint selectors (see
Versions).
- git:
git ls-remote --tags <url>, with tags optionally filtered bytag_regex. A namedversioncapture supplies the complete version. Namedmajor,minor, andpatchcaptures instead compose a dotted version. Optionalrevision,suffix,prerelease, andbuildmetacaptures are appended:revisionbecomes a fourth numeric component, whilesuffixis unseparated. For example,ref = "{{major}}{{minor}}{{patch}}"withtag_regex = '^(?P<major>\d{4})(?P<minor>\d{2})(?P<patch>\d{2})$'maps the tag20260810to version2026.08.10and renders it back to the same ref. A ref ending{{suffix}}{{revision_suffix}}can likewise round-trip eithermicrocode-20230516aas2023.05.16aormicrocode-20260210-rev1as2026.02.10.1. Without named version captures, pekit extracts the version from the ref template or an embeddedMAJOR.MINOR.PATCH; unnamed capture groups only filter tags. - tracked-path git: shallow-fetch the fixed ref and expose matching
versions already in
pekit.lock, plus one newly synthesized date version only when the current tracked blob differs from the newest lock. - url: fetch
listing_urlwhen set, otherwise derive a directory listing from theurltemplate (the part before the first{{…}}, up to the last/); filter entries byfile_regex, and extract versions from the matches. Named captures have the same mapping semantics as Gittag_regex: for example,(?P<major>\d{4})(?P<minor>\d{2})(?P<patch>\d{2})exposes20260810as2026.08.10, which a URL containing{{major}}{{minor}}{{patch}}renders back to the upstream filename. Without named version captures, Pekit extracts an embedded version as before. - PyPI: fetch the project's standardized JSON Simple API page once per
invocation and enumerate the eligible sdists described under
[source.pypi].
Local and sourceless recipes cannot enumerate
(version_enumeration_unavailable) — they require an exact version. The
versions field on a git, url, or PyPI source acts as a cap applied to the
enumerated (or requested) set; version selection itself is documented on the
Versions page.
Source roots and provenance #
The source root is the directory targets execute in and the base that
@source: file references resolve against. Every
resolution records a provenance ref identifying exactly what was
materialised:
| Kind | Provenance ref |
|---|---|
| sourceless | recipe:<recipe-root> |
| local | local:<path> |
| git | git:<url>@<commit> |
| tracked-path git | git:<url>@<commit>:<path>#sha256:<blob-sha256> |
| url (checksummed) | url:<url>#<checksum> |
| url (locked, no checksum) | url:<url>#sha256:<hash> |
| url with remote patches | the locked url ref above plus +patches:sha256:<series-hash> |
| url (no checksum, no lock entry) | url:<url> (marked unanchored — reachable only in a dry run, since a real resolve locks) |
| PyPI | pypi:<normalized-project>@<version>#sha256:<hash> |
For git, url, and PyPI sources the provenance is also written to a source.pekit.json
manifest beside the materialised tree, and pekit emits it as a source event
under --verbose.
Delegation #
A recipe can borrow behaviour from a pekit.toml that lives inside its
materialised source tree, using the [delegate] table (or the shorthand
delegate = true, which enables everything):
= 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.
Plain pekit lint acquires one delegated source snapshot and statically checks
these merged recipe and package definitions without building. An explicit
version or source selector additionally enables payload rules against that
selection's existing build stage.
Where to go next #
For how enumerated versions are selected and rendered, read Versions.
For turning the materialised tree into package payloads, read Packaging files.
For the exhaustive [source] schema, read Recipe format reference.
Versions
pekit / Writing recipes
A pekit build is almost always about a version of some upstream software. The
recipe declares where source of a given version comes from and how to name the
packages it produces; the invocation decides which versions are in scope. This
page covers the version grammar pekit accepts, the {{...}} variables that carry
version components into your recipe, and how the --version, --latest, and
--all-versions selectors resolve against a source.
Version forms #
A version is written as:
NUMERIC_COMPONENT[.NUMERIC_COMPONENT...][SUFFIX][-PRERELEASE][+BUILDMETA]
The numeric core contains one or more dot-separated decimal components. An
optional unseparated SUFFIX starts with an ASCII letter and continues with
ASCII letters or digits; this covers upstream schemes such as IANA tzdata's
2026c. The optional -PRERELEASE and +BUILDMETA tails may each contain
digits, ASCII letters, dots, and hyphens.
| Written version | major | minor | patch | complete numeric core | suffix | prerelease | buildmeta |
|---|---|---|---|---|---|---|---|
2 | 2 | 2 | |||||
2026c | 2026 | 2026 | c | ||||
2.43 | 2 | 43 | 2.43 | ||||
2.43.1 | 2 | 43 | 1 | 2.43.1 | |||
0.5.13.5 | 0 | 5 | 13 | 0.5.13.5 | |||
1.21.0-rc.1 | 1 | 21 | 0 | 1.21.0 | rc.1 | ||
1.21.0+build.5 | 1 | 21 | 0 | 1.21.0 | build.5 | ||
1.21.0-rc.1+bld | 1 | 21 | 0 | 1.21.0 | rc.1 | bld |
Anything that does not match this grammar is rejected with an invalid_version
error. Comparison and ordering use every numeric component, without a
machine-integer size limit. A missing component counts as 0, so 1.2,
1.2.0, and 1.2.0.0 have equal numeric cores. Suffixes sort after the
matching bare numeric core and lexically among themselves (2026 < 2026a < 2026b); the prerelease string then breaks ties. Build metadata does not affect
ordering.
Version template variables #
Nine variables expose the parsed components of the selected version. They render
anywhere pekit expands {{...}} templates in a recipe — most importantly in
source refs and URLs, and in
package version and metadata fields. (These nine are
the version-derived subset of the template engine; the complete variable table,
including the multi-package-only {{multipack}}, is in
Supporting files.)
| Variable | Expands to |
|---|---|
{{version}} | the version exactly as written |
{{major}} | the first numeric component |
{{minor}} | the second numeric component |
{{patch}} | the third numeric component |
{{revision}} | the fourth numeric component, or empty |
{{revision_suffix}} | -rev plus the fourth component, or empty |
{{suffix}} | the unseparated suffix, or empty |
{{prerelease}} | the prerelease tail, or empty |
{{buildmeta}} | the build-metadata tail, or empty |
[]
= "https://github.com/example/tool.git"
= "v{{version}}"
Three rules govern rendering:
-
The fourth numeric component is also available as a corrective revision. The first three component variables retain their compatibility meanings. For example,
2026.02.10.1renders unchanged through{{version}}, exposes1through{{revision}}, and renders-rev1through{{revision_suffix}}. Fifth and later components remain available only through{{version}}. -
Referencing a component the version does not have is an error.
{{version}}fails when no version is selected at all;{{minor}}and{{patch}}fail when the selected version stops short of that component (for example{{patch}}against2.43).{{suffix}},{{prerelease}}, and{{buildmeta}}are the exception — they render as empty text when absent rather than erroring.{{revision}}and{{revision_suffix}}behave the same way. -
Unknown variables are an error. Any
{{name}}that is not one of the nine above (or the multi-package{{multipack}}token) is rejected. There is no silent pass-through.
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: an ordinary
[source.git] source (via git ls-remote --tags), a tracked-path git source
(from matching lock history plus a changed blob at its fixed ref), a
[source.url] source (by fetching the
listing directory), or a [source.pypi] source (from the project's JSON Simple
API page). --latest, --all-versions, and any constraint require one;
against a non-enumerable source they fail with selected source cannot enumerate versions.
A URL source with one exact versions value (for example, "= 1.83.0"),
no listing_url, and no patch series has a singleton version set. Automatic
selectors use that declared version without requesting a directory listing.
This supports pinned bootstrap inputs on hosts that serve artifacts but do
not expose indexes. Artifact download, lock checks, and signature verification
still apply. A range such as ">= 1.83.0" still requires discovery, and an
explicit listing_url is always consulted.
Selecting from the lock #
Discovery runs every time: --latest on Monday and --latest on Tuesday can
build different versions if upstream released in between. For a run that must
stay on one version set, such as a workspace rebuilt in several rounds, lock
the versions once and add --locked:
pekit workspace lock --latest
pekit workspace publish --all --latest --locked
With --locked, --latest, --all-versions and constraints draw their
candidates from the [[source]] entries in the recipe's
pekit.lock instead of
upstream. The source's own versions cap still applies. A recipe with no
locked versions fails with version_selection_empty; a recipe with no source
runs once, as it does without the flag. --locked only chooses versions:
fetching the chosen source and checking it against the lock work as usual.
--locked is refused on its own, with an exact --version (which does not
enumerate anyway), and on lock, whose job is to discover.
--version as an exact selector #
The simplest form is one or more exact versions, comma-separated:
pekit build tool --version 2.43.1
pekit build tool --version 2.43.1,2.42.0
A comma-separated list without spaces is treated as a set of exact versions. When the source is reproducible and enumerable, each written version is matched against the versions the source actually publishes using the trailing-zero ladder described below.
--version as a constraint #
A --version value that contains a space or a comparison operator
(<, >, =, <=, >=) is treated as a constraint and takes the
enumeration path. Constraints filter the enumerated set:
pekit build tool --version ">= 2.40"
pekit build tool --version ">= 2.40, < 3.0"
Multiple constraints (comma- or space-separated) are ANDed together. The ~ and
^ operators are not supported and produce
unsupported version constraint operator. A bare * matches everything.
--latest and --all-versions #
pekit build tool --latest # highest available version only
pekit package tool --all-versions # every available version
--latest picks the single greatest version after enumeration and filtering.
--all-versions selects the whole set.
The trailing-zero ladder #
Upstreams are inconsistent about trailing zeros: one project tags v2.43.0,
another tags v2.43, a third tags v2. So when you ask for an exact version
against a reproducible, enumerable source, pekit does not demand a character-exact
tag. It builds a ladder of candidates from your written version, most specific
first, and picks the first candidate the source actually offers:
| You 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 |
2.43.7.0.0 | 2.43.7.0.0, then 2.43.7.0, then 2.43.7 |
Only trailing zero components are dropped — a non-zero component is never
elided. The ladder works across any number of numeric components. It is also
skipped entirely when the version carries a
prerelease or build-metadata tail: 1.21.0-rc.1 is only ever matched as written.
If no candidate is available (or the source is not reproducible/enumerable), the
version you wrote is used verbatim.
PyPI and tracked-path git exact selectors are the exceptions: they are used literally and do not consult the live index or moving ref for ladder matching. This lets a PyPI version rebuild from its pinned URL and SHA-256, and a tracked git version rebuild from its pinned commit/path/blob, when upstream is unavailable. Their automatic selectors already expose exact canonical version spellings.
Tracked-path git synthesizes numeric versions from the UTC discovery date:
YYYY.MM.DD, then .2, .3, and so on for further changed blobs locked on the
same date. Unchanged bytes create no version. --all-versions combines every
matching historical lock with the current changed blob rather than treating
the branch tip as the whole history.
The ladder applies to the exact-selector path only. --latest,
--all-versions, and constraints work on the enumerated set directly and do not
ladder.
Source version caps #
A source can declare a hard ceiling (or floor) on the versions a recipe will ever
build, independent of what the invocation asks for. Set versions under the
source table:
[]
= "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. A concrete
package is named by its canonical reverse-DNS identity; unqualified package
aliases are not a supported compatibility layer. A virtual capability instead
names an interchangeable interface, such as a soname (libc.so.6), a
pkgconfig(...) token, or an explicitly documented role such as sh. The constraint is any
peipkg constraint expression (">= 1.2", "< 2", and so on). Both "*" and the
long form below with the constraint omitted mean any version.
The table form: placing a dependency in a named root #
An entry may instead be a table, which is the only way to attach a placement root — the named root the dependency should be installed into, when it differs from the depender's own root:
[]
# 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.
Retaining an older library ABI #
A runtime compatibility package contains the actual maintained older libraries, with their original SONAMEs. It does not alias a new ABI to an old filename or claim to replace a development interface. The resolver can install both versions when the required library filenames differ.
For OpenSSL, org.openssl.openssl tracks current upstream and supplies the
command and development interface through its normal split packages.
org.openssl.openssl3 follows authenticated 3.5 LTS patch releases and supplies
libcrypto.so.3, libssl.so.3, the OpenSSL 3 engines, and its legacy provider.
It has no command, headers, static libraries, or unversioned linker symlinks.
The current OpenSSL 4 command uses openssl rehash; upstream removed the
separate c_rehash command. This runtime package does not restore that command.
ELF dependencies on the .so.3 names select it automatically. A package that
explicitly pins an obsolete concrete OpenSSL package revision still needs its
metadata updated; ABI compatibility does not override version constraints.
OpenSSL 3 reads /etc/ssl/openssl3.cnf and
/etc/ssl/ct_log_list-openssl3.cnf; package defaults are installed under
/usr/etc/ssl. Its provider directory is
/usr/lib/x86_64-linux-peios/ossl-modules-3. OpenSSL 4 retains the canonical
configuration and provider paths. Both use /etc/ssl/cert.pem and
/etc/ssl/certs for the system trust store. Administrators with custom OpenSSL
configuration should apply the settings required by older consumers to the
OpenSSL 3 configuration too; explicit OPENSSL_CONF and OPENSSL_MODULES
overrides remain application-specific and must match the selected ABI.
The compatibility recipe retains the 3.5 LTS maintenance line, supported upstream through April 2030 (upstream support dates). Its discovery bound does not freeze patch releases; the shorter-lived 3.6 line is deliberately excluded. Remove the compatibility package only when supported consumers no longer require that ABI.
Foreign static Linux targets #
org.libc.musl-sysroot supplies a private static sysroot for Rust's
x86_64-unknown-linux-musl cross target. Its headers, static archives and startup
objects live under /usr/lib/x86_64-linux-peios/musl. It installs no dynamic
loader, shared process libc or command, and provides no generic libc capability.
Peios applications continue to use the native glibc toolchain.
This target follows upstream musl's Linux ABI. It does not promise Peios-native CET shadow-stack support: upstream assembly, including nonlocal jumps, does not implement that contract. Do not force ELF property notes to advertise it. The native compiler and native application packages retain their normal CET gates.
The mandatory sysroot gate compiles and runs a self-contained static PIE against
the staged headers and archives. It exercises thread creation/join, setjmp /
longjmp, signal-mask restoration with sigsetjmp / siglongjmp, and the patched
GB18030 decoder. It also checks non-executable stack, RELRO, eager relocation,
compressed relative relocations and absence of a dynamic loader or shared-library
dependencies. These are cross-target functional and layout checks, not a claim
that foreign binaries satisfy every Peios-native execution policy.
Symbol version policy #
By default a soname dependency is derived at soname granularity only: the
name must match exactly, with no version constraint. That alone cannot establish symbol-version compatibility: it cannot express "needs at least the glibc that introduced
GLIBC_2.34".
A workspace may opt specific sonames into finer, version-aware derivation
through the [policy.symbol_versions] table in the workspace file. It maps a
soname to the symbol-version token prefix whose tokens are numbered on the
same scale as the providing package's version:
# in the workspace file
[]
= "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. It accepts symbol_versions and symbol_capabilities; unknown keys are errors. Recipes and package files cannot set it. When no workspace
policy is configured, every soname is derived at name granularity.
Exact ELF version capabilities #
Libraries such as libstdc++ number their ABI versions independently of their package releases. Select these SONAMEs explicitly:
[]
= ["libstdc++.so.6"]
Pekit derives elfver(libstdc++.so.6:GLIBCXX_3.4.30) and corresponding CXXABI
capabilities from the actual GNU ELF version tables. Providers advertise each
non-base version definition; consumers require each strong version need in
addition to the SONAME. These names carry no package-version constraint. The
library name scopes the token, so a definition from another library cannot
satisfy it. Weak version needs remain optional. Exact definitions are compared,
not an assumed numeric maximum or a hand-maintained GCC release mapping.
Symlinks and source/debug-source fixtures are excluded. A package's own library satisfies only the exact capabilities it defines. An incompatible bundled library, multiple objects supplying the selected SONAME, malformed version tables or unrepresentable names fail package creation. Such errors must not silently fall back to SONAME-only requirements.
For rollout, publish updated runtime providers before publishing consumers with these requirements. Existing binaries and manifests are not retroactively qualified. Consumers originally linked against an unversioned runtime need relinking against a versioned runtime; packaging cannot reconstruct symbol versions absent from the binary. An old provider with only a SONAME capability cannot satisfy a new versioned requirement. Normal resolver checks also reject runtime upgrades that would leave installed consumers' requirements unsatisfied. This does not pin an exact GCC release or guarantee compatibility across arbitrary C++ ABI-changing compiler flags; normal upstream ABI gates still apply.
Build dependencies exported to the build command #
Distinct from everything above, a build target in a recipe may declare
[build.<target>.dependencies.<provider>] — a set of dependencies grouped under
one or more provider names. These never enter the package manifest. Instead,
pekit renders them and hands them to the build command through the environment,
so a build script can fetch or pin its build-time inputs.
[]
= "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.
Debian reference dependencies without a Debian package #
The e2fsprogs security-descriptor extensions depend on libpeios, which Debian
cannot install from APT. Its recipe therefore declares a private reference SDK
prerequisite. The input is the unchanged signed dev.peios.libpeios-source
0.5.0-1 publication, with its repository key, MIT licence, source commit and
verified archive hashes recorded beside the recipe. Acquisition refuses changed
bytes before extracting the source.
That older source publication did not contain a vendored dependency closure. The acquisition target runs its original lockfile-bound Cargo vendor command and retains the PKM UAPI from the same fixed Git revision. These generated inputs are captured in the e2fsprogs source bundle. The offline SDK target builds from them and runs the frozen source's tests, ABI/export checks and dynamic/static consumer probes. The main reference build receives the SDK as an explicit prerequisite.
The snapshot establishes the reference ABI0 dependency; it needs review when required APIs change or prerequisite security maintenance requires a refresh. It does not pin native or runtime libpeios packages and does not modify or republish the active first-party source tree.
Qualification evidence (2026-09-16): both providers passed the required build, test and archive lint gates. Each ran the upstream 19-test internal suite and 390-test filesystem suite with zero failures; 17 upstream skips remain (slow, privileged, unavailable optional features and platform exclusions). The Debian prerequisite ran 256 Rust tests and the frozen ABI consumer/export checks. Exact cbindgen 0.29.2 was unavailable, so the frozen prerequisite policy verified the committed ABI snapshot instead of regenerating it; it checked standalone C/C++ headers and 207 DSO exports.
All 22 resulting archives passed independent trusted-key signature and payload
hash verification. Both source bundles reconstructed 3,656 authenticated input
identities and the expected build dispatch. This checks source reconstruction,
not an additional rebuild. Candidate installation is still blocked by the
existing repository closure's missing libblkid.so.1; recipe qualification is
not a claim of image or public-release acceptance.
Claims #
A claim is how a package plugs into a shared filesystem name that at most one
installed package may hold at a time — the peipkg mechanism where several
packages can supply a name like /usr/bin/cc, but exactly one holder owns
the real file and the rest defer. See
claims for what this means at install time.
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, selected workspace env file, recipe, and selected recipe 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 these places, each contributing to the user layer:
- the workspace file (applies to every member),
- the selected env file at the workspace root (applies to every member),
- the recipe (
pekit.toml), - the selected env file beside the recipe (see below).
Composition and override #
Within the user layer the sources are applied in this order, and a later source overrides an earlier one on the same variable name:
- workspace
[env], then the selected workspace env file - delegated source recipe
[env], then that source's env file (only when the recipe delegates env — see Recipe anatomy) - recipe
[env] - the selected recipe env file's
[env]
So a variable set in the recipe overrides the same variable inherited from the workspace profile, and the selected recipe env file has the final say.
Shell expansion of [env] values #
For the common case — a shell target, or any target run under a [wrap] wrapper —
pekit emits the user variables as export lines in a shell script that runs before
your command, using double quotes that preserve spaces. This means [env] values are
expanded by the shell and may reference other variables:
[]
= "$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 at the workspace root or beside a recipe. It
carries an [env] block, a [wrap] wrapper, a dependency_provider, a
[sandbox] root preparer, a [lint] table, or any combination of them. It
lets you keep environment- or profile-specific settings out of the recipe
proper. An env file must declare at least one of env, wrap,
dependency_provider and sandbox (see
Isolated production jobs). The only other
recognised top-level key is lint, which adds rule exceptions that belong to
the environment rather than to any package.
Which env file is loaded is controlled by --env <name>. The default is main,
so env.pekit.toml is picked up automatically when present and silently skipped
when absent. Any other name selects a profile such as release.env.pekit.toml
(--env release). In a workspace, pekit loads that file from the workspace root
and then from the recipe directory; either may be absent, but a named profile
must exist in at least one location. A recipe-local file overlays the workspace
profile. Outside a workspace, the recipe-local file retains the same semantics.
If both paths resolve to the same underlying file (for example through a legacy
member symlink), pekit applies it only once. Passing --env none disables both
layers.
# release.env.pekit.toml
= "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] - selected workspace env file
[wrap] - delegated source env file
[wrap](only when the recipe delegates wrap) - recipe
[wrap] - selected recipe env file
[wrap]
So a recipe wrapper overrides the workspace profile's, and the selected recipe env file's wrapper overrides the recipe's.
dependency_provider #
Env files may also set dependency_provider, a single string naming which of a
target's declared [build.<target>.dependencies.<provider>] blocks is exported
to the build environment (PEKIT_DEPENDENCIES, PEKIT_DEPENDENCY_PROVIDER,
PEKIT_DEPENDENCIES_FILE). It is not an environment variable; it selects a
declared provider block by name. The precedence is the same as for wrappers:
selected workspace profile, delegated source env file, then selected recipe env
file. The last non-empty value wins. See Dependencies and
claims for what the selection exports.
Keyrings #
Keyrings supply keys to the coordinator. Targets receive no keyring entries by
default. A target requests specific public or acquisition inputs with
keyring_inputs, and the operator's keyring must separately grant the matching
access. Private signing keys remain coordinator-only. Keyring files and inline
literals continue to resolve through the same precedence rules.
--keyring=<value> (keyring files) #
--keyring names a keyring file and is repeatable. The value is resolved like
this:
- 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.
# coordinator-only; never exported to the target
Literals are keyed by their dotted path, so repeating the same path replaces the earlier value with the later one.
Precedence #
Keyring sources combine in this order, lowest to highest precedence:
earlier --keyring file < later --keyring file < --keyring.x.y= literal
That is: keyring files overlay in command-line order, and all inline literals are
applied last, so a --keyring.x.y= value always overrides whatever a keyring file
provided for the same exported name. Any resulting PEKIT_KEYRING_* name that
would collide with a managed PEKIT_* variable or with a user [env] variable is
an env_collision error.
Package signing #
The well-known package-signing entry is read by Pekit itself:
signing.package_key names the Ed25519 private key that signs every
peipkg-format artifact a package or publish run produces. Because it rides
the keyring mechanism, the key path stays in a per-developer, gitignored file —
or arrives inline as --keyring.signing.package_key=<path> — and private key
material never enters a recipe. Without a key, publish refuses peipkg
packages unless you pass --allow-unsigned. The exact behaviour and the
accepted key encodings are in
Supporting files.
A [publish.peipkg] target may similarly select any keyring leaf with a
keyring:<dotted.entry> signing_key value. The leaf contains the path to the
Ed25519 private key used to sign repository metadata; the conventional name is
signing.repository_key. This is target-selected rather than a second
well-known entry because different publish destinations may use different
repository keys.
Binary signing #
A build target's sign table
also reads the keyring, but through entries the recipe names rather than a
fixed one: "bin/peinit" = "tcb.priv" signs that output file with the key
whose path the tcb.priv leaf holds. Any leaf will do; the convention in
Peios' own recipes is [tcb] priv = "<path>". See
Signing binaries for PIP.
Keyring file format #
Legacy string leaves remain supported as coordinator-only values. To expose a
value to a worker, use a typed entry with value and access:
# prod.keyring.pekit.toml — operator-controlled and gitignored
[]
= "/secure/tcb.pem"
= { = "PUBLIC_KEY_HEX", = "public" }
[]
= { = "TOKEN", = "acquisition" }
# pekit.toml
[]
= ["tcb.pub"]
= 'generate-header "$PEKIT_KEYRING_TCB_PUB"'
[]
= ["sources.registry_token"]
= 'fetch-sources'
public inputs are available to requesting targets. acquisition inputs are
restricted to build.vendor. signing entries and legacy strings cannot be
requested by workers. An inline override resets the entry to coordinator-only,
so it cannot accidentally inherit a public grant from an earlier keyring file.
Values are literal strings, including public certificate PEM content; a path
value does not grant filesystem access. Typed path and content entries remain
unsupported. Colliding normalized entry names are rejected.
A note on secrets in output #
Explicitly granted worker inputs are exported like other environment variables.
pekit does not redact them from a target's stdout/stderr — if your command
prints a secret, pekit streams it verbatim. pekit's own diagnostics and its
--verbose environment summary log variable names only, never values, but you
are responsible for not echoing secrets from inside your commands. Prefer writing
secrets to files or consuming them directly rather than printing them.
Isolated production jobs #
A workspace can require Pekit-controlled isolation:
# workspace.pekit.toml
[]
= true
= ["_pybuild_"]
The selected workspace environment profile prepares a dependency root. Put
the profile most jobs use in the workspace's env.pekit.toml, so commands run
isolated without --env, and keep named profiles such as debian.env.pekit.toml
for the exceptions:
# env.pekit.toml
= "peipkg"
[]
= 'exec "$PEKIT_WORKSPACE_ROOT/_peiroot_/enter.sh"'
= ["build:vendor"]
= ["/usr/libexec/peiroot/entry"]
The preparer receives PEKIT_SANDBOX_ROOT, a fresh destination path, and
PEKIT_JOB_STATE, coordinator-only job storage. It installs declared dependencies
and exits. It receives managed variables, not recipe environment expansions or
keyring values. Pekit then runs the target through Bubblewrap with a private PID,
mount and user namespace, a cleared environment, and no network by default.
A profile may set network_targets = ["build:vendor"]; this shares
the host network for acquisition, including its DNS configuration, without
mounting the host root or home. Other target names cannot enable network access.
The worker receives its dependency manifest through a read-only file at
PEKIT_DEPENDENCIES_FILE.
A profile may also name an entry program, which the preparer places in the
root. Pekit runs it inside the sandbox ahead of each target command and it
execs the command, so a profile can start job-scoped services that the
isolated root needs; anything it leaves running ends with the job. The Peios
default profile uses this for name resolution during acquisition. Peios glibc
resolves hosts only through resolvd's socket, which no sandbox runs, so a
native acquisition root gets resolvd's NSS module and a small build-root
resolver that answers on that socket from the copied resolv.conf.
That profile acquires in a native root built from the vendor target's peipkg
dependency set, adding only a shell, Python for the resolver, the NSS module
and TLS trust; recipe constraints take precedence. A vendor target with an
empty peipkg set is rejected.
Delegated and member [env] layers still apply inside the worker. Their wrappers
cannot replace workspace isolation or its dependency provider. --env none or a
profile without a root preparer fails before target outputs are cleared. Sandbox
profiles outside an isolation-enabled workspace are rejected rather than run
unprotected.
Authenticated source caches remain outside the worker. The source receives a
writable copy by default, at the same visible path: no allow_write toggle is
needed. Job outputs retain their paths across stages, and completed stages in the
same job may be modified by later stages and tests. Sibling recipes, repository
indexes, signing keys, host home and completion records are not mounted. Shared
workspace inputs must be named explicitly and are copied into read-only mounts.
Copies exclude Git databases, keyrings, configured key files and common local
credential files; source trees must still contain only intended build inputs.
Pekit serializes invocations of the same recipe. A worker's descendants are gone before signing starts. PIP and module signatures are applied by the coordinator; package/publish reapplies signatures after gates that may have rebuilt outputs. Signing paths that escape the stage through symlinks are rejected.
--no-build retains both staged outputs and the private source copy. Outputs
created before isolation was enabled must first be rebuilt; their old stages may
contain leaked signing material and are not exposed to new workers. An explicit
gen runs against a private copy and applies its source changes through a bounded
filesystem root after successful completion. Concurrent source edits cause it to
abort. verify never writes the source checkout back.
The Peios root preparer snapshots signed repository metadata once per job and records each resolved dependency closure. Debian preparation records installed package versions and its base image identity. This is automatic build provenance: recipes can continue selecting current upstream releases and wildcard dependency versions. A new job selects current inputs again; no hand-maintained version pins are required.
Where to go next #
For the managed PEKIT_* variables these layers sit on top of, read the
environment contract in the command-line reference.
For the exact env-file and keyring schemas, read Supporting files.
For the signing key that rides the keyring mechanism, read Signing and provenance.
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 + gates | multiple | yes | Stages builds, runs gated tests, writes .peipkg artifacts under out_dir, then lints them. Recipes with a reproducible source also emit a corresponding-source package. |
publish | package members (as package) | multiple | yes | Packages as above, then publishes to configured localdir and/or Peipkg repository destinations. |
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. |
lint | the recipe and every package it defines | none, or multiple | no | Checks the effective tree against lint.pekit.toml, acquiring delegated recipe files when needed; with an explicit version/source selection, also checks the payload from an existing build stage. Never builds. |
workspace | a delegated command across members | (delegated) | (delegated) | Runs one of the above across every workspace member. |
help | a command name, optionally | none | no | Prints the overview, or one command's flags and selectors. --help / -h anywhere do the same. |
version | nothing | none | no | Prints the pekit version and build commit. |
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;
--no-gates is package/publish only; --strict is
build/test/package/publish only; --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.
Gates #
pekit package and pekit publish run two kinds of gate: gated tests before
any artifact is written, and lint over the artifacts before any is published.
Gated tests #
A test target with gate = true is part of what the recipe's packages promise:
[]
= ["main"]
= true
= "./tests/release-check"
pekit package and pekit publish stage the union of the selected packages'
build requirements and every gate's needs, then run each gate once for the
resolved source version. Package artifacts are written only after all gates
succeed. A failed gate therefore cannot create or publish a new artifact.
A gate succeeds when its command exits successfully. Some upstream runners
print failed comparisons but still exit zero; wrapping one in make check does
not fix that. Recipes must propagate comparison failures and verify that the
intended tests actually ran. Record skipped tests and missing test prerequisites
separately from a passing gate.
Gates are recipe-wide: selecting one package from a multi-package recipe still
runs every gated test in that recipe. pekit build does not run gates, and an
explicit pekit test runs a selected target whether or not it is a gate.
The lint gate #
Once the artifacts are written, pekit runs the recipe's
lint policy over them: the static rules against the
recipe, then the payload rules against the archives themselves, read back from
the signed files rather than from the build stage. Symlinks, derived
dependencies and anything else packing produced are judged as they will ship.
Any finding stops the run before publication, leaving the artifacts on disk for
inspection. A recipe with no lint.pekit.toml in its chain skips the lint gate.
When only some of a recipe's packages are selected, the others are still
resolved from their build stages for cross-package lookups — a -devel
symlink into the runtime package, a binary's debug file in -debuginfo — but
only the selected ones are linted.
Skipping gates #
For rapid local iteration, pekit package --no-gates and pekit publish --no-gates skip both the gated tests and the lint gate, and print a warning
naming them. --no-build only controls build-stage reuse and never implies
--no-gates.
Strict mode #
--strict holds build, test, package or publish to the contract a
reviewed publication needs:
- the workspace (or, outside one, the recipe) is a git checkout with no
uncommitted changes, apart from
pekit.lockfiles, whichlock --latestmaintains unattended and which verify themselves against upstream; - every resolved source is pinned in
pekit.lock; a--localsource or an unlocked one is refused; - no bypass flag is present:
--no-gates,--no-build,--no-verify,--local,--prefer-local,--allow-unanchoredand--allow-unsignedare rejected when the command line is parsed.
On publish, --strict also has the Peipkg repository publisher check the
repository it is about to produce before writing anything. Every active package
must resolve a fresh install closure on each architecture the repository serves,
and every previously active package's closure must upgrade cleanly to the new
set. Each closure is checked for missing dependencies and capabilities,
conflicts, root placement and two packages shipping the same path. Ordinary
publish omits these checks, so a bootstrap can publish packages ahead of the
dependencies they need.
Stages that did not finish #
A stage directory on its own says nothing about whether the target that wrote
it completed, so pekit records the outcome beside it. A target marks its stage
as running before it touches anything and complete only after it succeeds, and
the marker lives in the work base's .pekit/ metadata rather than inside the
stage, which becomes a package payload.
--no-build reuses a stage marked complete. A stage whose target failed, or
was interrupted, is refused:
stage_incomplete: build:source: stage .../out/build/source is left over from a
run that did not finish; drop "source" from --no-build to re-stage it
On a normal run that rebuilds the target, pekit clears a stage explicitly
marked as still running before executing the target again, even when its
recipe does not set clear_out. This also applies in an isolated workspace:
the incomplete output is discarded, then the isolated job rebuilds from an
empty stage. A dry run neither clears the directory nor changes its marker.
This recovery does not permit reuse of incomplete output with --no-build.
An isolated workspace still refuses unverified legacy output with no
completion record, an unrecognised record, or a complete record unless the
target explicitly clears it. Records from failed root composition or an
interrupted command can therefore be retried without manual output cleaning,
while unknown legacy output keeps its existing refusal.
Without that, a failed stage is silently reused and the run fails later somewhere unrelated — a clean that gave up part-way leaves a tree too full to replace, and a fetch that failed leaves one too empty to reuse, and neither names the stage as the cause.
A stage staged before this tracking existed has no marker at all. That is
treated as unknown rather than failed and is still reused, with a note saying
its state is unverified — refusing it would turn an explicit --no-build into
a rebuild, which is the opposite of what the flag asks for.
Package stages and recipe provenance #
Each package gets its own stage under <work base>/package/, named for the
package's identity — its name, version, architecture and format — and the
finished artifact is left there for collection. Because a run packages only
the selected packages, stages of packages you did not select survive
untouched, and so do stages left by earlier runs.
An artifact records the recipe it was built from,
so a surviving stage would otherwise keep offering an artifact whose
recipe_ref describes a state of the recipe that has moved on: commit the
recipe, package one member, and every other member's stage still claims the
old commit and +dirty. To prevent that, pekit stamps each package stage with
the provenance its artifact was packed with — the recipe ref, the builder and
the source ref — and, before packing anything, drops every package stage whose
stamp does not match the current run, selected or not. The stamp lives beside
the stage in the work base's .pekit/ metadata, for the same reason completion
markers do.
Two consequences are worth knowing:
- Committing the recipe repackages, but never recompiles. Only package
stages carry this stamp. Build stages are keyed on their own inputs and keep
their completion markers, so
--no-buildstill reuses a completed build after any provenance change, and packaging repeats only the packing. - A stage packed by an older pekit is dropped. It carries no stamp, so its provenance cannot be shown to match. A package stage is a cached output rather than a build input, so repacking it costs nothing but the pack.
Two dirty work trees at the same commit produce the same recipe_ref and so
the same stamp, because +dirty marks rather than
describes:
commit the recipe when you need an artifact whose provenance identifies its
inputs exactly.
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.
Pekit accepts only an out_dir strictly below the recipe root. It rejects the
recipe root itself, its parents, and paths outside it before any command runs,
so a typo such as out_dir = ".", "..", or "/" cannot make clean
recursively remove source or a broader workspace. If the child path is a
symlink, removing managed output removes the symlink rather than following it;
a symlink in any parent component of a nested out_dir is rejected.
--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.
pekit workspace clean --output-only removes managed output without running
recipe clean targets or fetching delegated source. It needs no build environment
or keyring, even in an isolated workspace, and respects each recipe's active-job
lock. Ordinary clean targets still require the configured sandbox environment.
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 — that is the pekit version command).
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. pekit help and pekit help <command> give the one-screen summaries;
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. An existing local path, resolved relative to the invocation
directory, takes precedence for both recipe and workspace locators. For example,
--recipe com.git-scm.git selects that local recipe directory without needing a
./ prefix. A value with no local match 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/*"]
[]
= "C"
[]
= ["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 N,--fail-fastand--journal <file>— 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 the workspace flags 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.
A failed member's explicitly incomplete target stage is cleared when that
target is rebuilt on a later run. This applies under workspace isolation as
well; it does not allow --no-build to reuse incomplete or unverified output.
See Stages that did not finish.
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
dependency 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.
Resumable runs: --journal #
--journal <file> makes a long run resumable. Each member that succeeds is
appended to the journal as it finishes, and the file is synced, so it survives
a power loss straight afterwards. Repeating the same run with the same
journal skips the members it lists (they count as skipped in the summary) and
runs the rest. A skipped member still takes part in
dependency order: the packages it defines carry their
runtime dependencies, so a member installing one still waits for whatever
those need. Members that failed, or never started, are not listed, so they
run again.
To stop a run cleanly, create <file>.stop. At the next point where a member
finishes, pekit starts nothing more, waits for the members still running, and
exits with workspace_stopped. It removes the stop file as it honours it, and
also removes one left over when a new run starts. Anything stopped by other
means, such as a power-off, loses only the members that were running.
$ pekit workspace --jobs 3 --journal round2.journal publish --all --latest --locked
$ touch round2.journal.stop # later: finish what is running, then stop
$ pekit workspace --jobs 3 --journal round2.journal publish --all --latest --locked
The journal's first line records the run it belongs to: the delegated command,
version selection (including --locked), --all, --replace, --strict,
--no-gates, --env, tags and selectors. A journal from a different run is
refused with journal_mismatch, so one round's journal cannot skip another
round's members. --jobs and --fail-fast are not part of it and may change
between attempts. --dry-run reads an existing journal but never creates or
writes one. The journal is opt-in; without it a workspace run behaves as
before.
Dependency order #
For build, install, test, package and publish, pekit orders members so
that a member producing a package runs before the members that install it. The
aim is the best chance of a whole batch succeeding, even into an empty
repository.
A member waits for another when the dependencies of its gate targets name a
package the other member defines. Gate targets are the ones the command
installs dependencies for: build targets for build and install, build and
test targets for test, and build targets plus gated tests
for package and publish (only build targets under --no-gates). Only the
dependency set of the selected env's dependency_provider counts: under an
env whose provider's names no member produces (a foreign distribution's
packages, say), there are no edges and members are independent.
Runtime dependencies count too. When a member needs a package another member
defines, it also waits for whatever that package's own runtime
[dependencies] name, followed
transitively through member packages, provided the package declares the same
dependency_provider. Names
no selected member defines are not ordered: they resolve from whatever the
provider already has, as before.
Scheduling:
- Among members whose waits are satisfied, lower ids start first;
--jobsruns several at once. - A member starts even if one it waited for failed. It then resolves that dependency from what the provider already has.
- A dependency cycle cannot be ordered. When nothing is runnable and nothing is running, pekit starts the blocked member with the fewest unfinished dependencies (lowest id on a tie) and carries on. There is no warning: building a real cycle from nothing needs a separately built seed, which tags let you select.
Ordering reads each member's effective recipe, resolving its source as the
member's own run will. Under --dry-run only already-cached sources are
visible, and --dry-run or --verbose reports each member's waits as
workspace_order events. A member whose recipe cannot be resolved is not
ordered, and reports its failure when it runs.
Selecting members by tag #
A recipe may label itself with tags:
# pekit.toml
= ["bootstrap"]
After the delegated command, --tag <tag> runs only members carrying any
given tag, and --exclude-tag <tag> skips members carrying any given tag.
Both repeat, apply after include/exclude, and fail with empty_workspace
when nothing matches. Tags are catalogue policy, so only the member recipe's
own tags count, never a delegated source's.
Tags and ordering together are how a distribution bootstraps from nothing: build
the tagged seed in an environment that needs none of the catalogue, then
everything else in dependency order, then the seed again natively, then
everything else against the native seed. Here the workspace's default profile is
the native one, and debian is the named profile used only for the seed. The
later rounds publish versions that already exist, so they overwrite them with
--replace, which is only for a repository nobody
consumes yet:
$ pekit workspace publish --all --latest --tag bootstrap --env debian
$ pekit workspace publish --all --latest --exclude-tag bootstrap
$ pekit workspace publish --all --latest --tag bootstrap --replace
$ pekit workspace publish --all --latest --exclude-tag bootstrap --replace
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 or a stop request, members
already finished in the journal, 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 requirements #
The [policy] table carries distro-wide derivation policy.
[policy.symbol_versions] is a map from a shared-library soname
to the symbol-version token prefix whose tokens are commensurable with the
providing package's version:
[]
= ["libstdc++.so.6"]
[]
= "GLIBC_"
= "GCC_"
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.
symbol_capabilities derives every required and provided version node directly,
including both GLIBCXX and CXXABI for libstdc++. Its token numbers are not GCC
package versions. Publish capability-bearing runtimes before consumers.
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.
Named env files are workspace defaults too. A selected
<name>.env.pekit.toml at the workspace root applies to every member; a
same-named file beside one recipe overlays it. This lets a workspace define
shared build rungs without per-member symlinks.
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.
A [publish.peipkg] target additionally needs the private key that signs its
repository descriptor and indexes. Its signing_key may name a key file
directly or use keyring:<dotted.entry> to read a key-file path from a keyring,
for example keyring:signing.repository_key. This is deliberately separate
from the well-known signing.package_key: one key may perform both roles, but
the repository publisher also supports distinct package and metadata keys.
When Pekit initializes a repository it records both public keys when they differ. When publishing to an existing repository, that repository's signed descriptor must already trust the relevant keys; Pekit never widens an existing trust set as a side effect of publishing.
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 any of the build's own inputs are uncommitted — 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.
What makes a recipe dirty #
+dirty answers one question: did anything that could change this artifact
differ from that commit? So the marker covers the build's own inputs, and
nothing else:
| Counts as dirty | Does not |
|---|---|
The selected recipe directory — pekit.toml, its package definitions, pekit.lock, source patches, embedded upstream keys | Other recipes in the same repository, whether or not workspace fan-out selects them |
Inherited workspace inputs beside the members — workspace.pekit.toml, shared package defaults, environment files, keyrings | Pekit's managed output: a member's out_dir, as a real directory or as a link into shared staging |
| Anything else uncommitted inside those paths, tracked or not | The destinations a run publishes into, including a local peipkg repository |
Within that scope a dirty tree is over-marked rather than under-marked,
because a commit id alone does not describe a build whose inputs had local
changes: pekit marks the artifact, rather than trying to decide which edits
mattered. That is also why two differently dirty work trees at the same commit
produce the same recipe_ref — +dirty says the commit is not the whole
story, not what the rest of it was.
A shared recipe repository is the reason for the scope. Without it, editing any
package taints the provenance of every package published from that repository
until the edit is committed, and an operational output or repository link
beside a member taints an otherwise exact commit — which is not something a
repository should have to solve with .gitignore entries.
Provenance also decides how long a staged artifact stays usable. Package
stages are stamped with the recipe_ref, builder and source_ref they were
packed under, and a packaging run drops every stage stamped differently before
it packs anything — so committing the recipe, or dirtying the tree, cannot
leave an artifact behind whose recipe_ref describes the recipe as it used to
be. Only packing repeats; completed build stages are untouched and
--no-build still reuses them. See
Package stages and recipe provenance.
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/PyPI — 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' licenses and any source_package.license. The recipe declares
that key for material only the source carries, such as a test program no member
ships. Terms are deduplicated and sorted, and a term containing OR stays
parenthesised. Its license class is the
most restrictive class declared by any member, so an all-free family remains
free, while an omitted or unknown member makes the corresponding source
package unknown. 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 an ordinary git source, agit archiveexport of the locked commit — deterministic for a commit and independent of the mutable checkout. For tracked-path git, that archive is restricted to the one locked relative path; no other repository file enters the source package. A URL source's upstream-maintained patch series is included byte-for-byte underupstream/patches/, in the same order and with the same hashes as its nested lock entries.source/— the prepared source tree before package targets run, including the applied upstream and distribution patches. Reconstruction uses this tree directly, without fetching the upstream source or applying patches again.workspace/— the captured recipe tree at its original relative path, inherited workspace/package/lint policy, environment profiles, and declared shared inputs. Recipe-local test fixtures, tools, scripts and license files are included. Safe relative symlinks retain their topology; missing or escaping targets fail packaging. Empty fixture directories are preserved.recipe/andpatches/— compatibility views for older source consumers. New reconstruction tooling uses the completeworkspace/topology.acquisition/vendor/— captured outputs of an isolatedbuild.vendorstage, when it ran, including vendored language dependencies.build-environment/— dependency versions and artifact identities recorded by the selected root preparers. The catalogue Debian profile records an immutable prepared-root archive and supports explicit replay below. Native repository replay remains a separate provider contract.build-inputs.json,rebuild.pyandREBUILD.md— the versioned bundle manifest, reconstruction entry point and required tools. The manifest records file hashes, modes, link targets, the upstream version/ref and selected environment. Peipkg transport modes differ from source Unix modes; the reconstruction tool restores source modes after all hashes verify.
Small source bundles use schema 2. At 90,000 payload entries, Pekit uses
schema 4 and stores the prepared tree in deterministic prepared-source.tar.gz.
Compressing this inner archive leaves room for the pristine upstream archives
and acquisition inputs within the outer package's 4 GiB decompressed limit.
Individual source hashes, modes and link targets remain included. This also
keeps the outer package within Peipkg's 100,000-entry limit without dropping
source files. Both the compressed prepared archive and its decoded tar stream,
including headers and padding, are limited to 4 GiB. Too many remaining
non-source entries or an identity manifest exceeding 64 MiB fails packaging.
The identity manifest uses readable JSON when it fits, and compact JSON when
removing whitespace is needed to fit the same limit. Every recorded identity and
metadata field is preserved; compact manifests that still exceed the limit fail.
rebuild.py accepts schemas 2, 3 and 4. Historical schema-3 bundles retain their
uncompressed prepared-source.tar. For either archived form, it checks the
archive hash before reading it, extracts into a fresh temporary directory and
verifies every member against the identity manifest before moving the tree into
place. Missing, duplicate, escaping, sparse, hard-link and special-file entries
fail verification. Relative symlinks must resolve within the prepared tree.
Extraction does not use tar.extractall; it bounds paths, decoded bytes and
extended-header reads. Compressed streams are checked through their trailer,
including the checksum. An existing prepared tree is verified before reuse.
Verify the outer package signature before running any included script: these
checks detect changed inputs and do not make an unsigned script trustworthy.
An isolated job copies shared helpers before any target runs. Workers consume
those read-only copies, and the source package contains the same bytes. Recipe
and policy inputs are captured separately from writable worker trees. Editing
those inputs during a job, or before reusing its outputs, causes packaging to
fail with source_input_changed; rebuild with the new inputs. Retained outputs
from before source-input capture was introduced also require one fresh build.
The exporter excludes output/cache state, Git internals, ignored developer files, keyring files, known credential filenames and configured key-file paths. An explicitly declared input that is missing, ignored or escapes the workspace is an error. Keep credentials out of tracked recipe material. Copying build inputs does not authorize distributing private keys.
Workspace isolation.inputs are captured automatically. A recipe can add
source_package.workspace_inputs = ["helpers/python"] for its own read-only
shared inputs. Coordinator-side support, such as root preparation scripts,
belongs in workspace [source_package] inputs = ["_peiroot_", "_debroot_"];
this captures source without granting workers access to those directories.
Reconstructing a source bundle #
Verify the outer package signature using the distribution's trust configuration,
then extract it and run python3 rebuild.py test or python3 rebuild.py package
in its /usr/src/dist/<name>-<version>/ directory. The command verifies captured
hashes, restores source modes, and invokes Pekit with the included prepared tree,
the recorded version, and the original environment. PEKIT_REBUILD_ENV can
select another captured environment. Additional Pekit flags follow the command.
Install a compatible Pekit, Python 3, Bubblewrap and the selected profile's root
preparation tools. A native profile still needs its declared trusted package
repository; a fresh Debian environment needs Docker and archive access; retained-root replay
needs neither. For the catalogue,
provide that repository at workspace/_peipkgRepo_. These dependency services and
operator signing keys are supplied separately. A generic reconstruction may
rerun the declared language acquisition stage; its captured vendored sources
are also available for language-specific offline replay.
Retaining and replaying Debian build environments #
The catalogue's Debian root preparer separates networked dependency acquisition
from Pekit's offline build and test workers. A fresh job pulls the current
debian:trixie image once, records its immutable image ID and repository digests,
and creates every Debian root for that job by ID. APT package requirements use
concrete names, optionally qualified by architecture. A constraint is * or a
comma-separated conjunction of =, >=, >, <= and < comparisons. Debian
version ordering applies, including epochs and ~; semver ranges and virtual
package requests are not accepted. Before a single joint install, the preparer
filters available versions against the constraints, permitting a necessary
downgrade only inside the disposable root. It checks all final installed direct
dependencies after resolution. Failed archive refreshes and unsatisfied or
unsupported requirements stop acquisition.
Each completed root is exported and retained as <sha256>.tar.gz. Its contents
include installed package files, the dpkg database, APT configuration and signed
index metadata. The per-target debian-root.json records the requests, base
image, host architecture, preparer hash, archive hash/size and package inventory
hash. installed.tsv includes package name, version, architecture and dpkg state.
Both records enter the corresponding source bundle under build-environment/.
Large root archives remain in coordinator storage, outside source bundles and
worker mounts.
The default archive store is
${XDG_STATE_HOME:-$HOME/.local/state}/pekit/debian-roots.
PEKIT_DEBIAN_ROOT_STORE selects another directory, for example a backed-up
release archive volume. Archives are published atomically, verified before
reuse and extracted into fresh roots. Ordinary build cleanup cannot delete the
store. The preparer does not garbage-collect it: retain or back up every archive
referenced by a supported release's records. Source bundles alone do not contain
these dependency bytes. Deleting a referenced archive makes that historical
environment unavailable; the preparer does not silently reconstruct it from a
new archive state.
After verifying and extracting a corresponding-source package, replay its recorded Debian environment with:
Supply these variables to the coordinator process, not recipe [env] tables.
Replay requires Python 3.11+, GNU tar, Pekit and Bubblewrap, but invokes no Docker
or APT commands. It requires matching captured preparer bytes, architecture and
canonical requested dependencies, and verifies both inventory and archive
hashes. Every executed Debian target must have its own record; missing, corrupt
or incompatible records fail without a network fallback.
Language acquisition commands retain their explicitly declared network policy;
retaining the root alone does not turn a cargo vendor invocation into offline
source replay.
Reconstruction uses local provenance and does not claim a byte-identical signed release. Retained-root replay fixes the dependency filesystem; the host kernel, CPU capabilities, runtime mounts, clock, signing identities and independent reproducibility/publication qualification remain separate checks. New ordinary builds continue following upstream automatically; no manually maintained version pins are added.
Recipes with no reproducible external source, and explicit --local builds,
still do not emit an automatic source package. For in-repository products,
distribute the complete catalogue commit/tree (including shared support and
policy) with the release and record that identity, or give the product a
reproducible Git source so automatic source packaging applies. A binary alone
and a moving repository URL are not a complete corresponding-source offer.
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. - A Peipkg repository publication verifies every package before changing its
indexes. An untrusted package signer, duplicate package identity, damaged
artifact, or unusable repository metadata key is a
peipkg_repository_publisherror.
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.
Kernel module signing #
[]
= "modsig.priv"
= "modsig.priv"
The keyring entry names an ML-DSA-65 PKCS#8 PEM private key followed by its X.509
certificate. The coordinator signs the uncompressed relocatable ELF bytes,
verifies the detached CMS signature against that certificate, appends the kernel
signature trailer, and recompresses .ko.zst outputs. It uses its trusted OpenSSL,
never the build tree's scripts/sign-file. OpenSSL 3.5 authenticated attributes
require the kernel's ML-DSA auth-attribute compatibility setting.
Workers need only the public certificate in modsig.pub, granted with
access = "public" and requested through keyring_inputs. Kernel compilation
keeps signature enforcement enabled; modules_install suppresses its own signing
invocation with CONFIG_MODULE_SIG_ALL=. The coordinator then signs both module
sets before dependent tests run. Firmware tests similarly prepare fixture blobs
in a build target, apply sign.pip in the coordinator, and consume those signed
fixtures in the test worker. Production private keys never enter worker source or
output trees.
Linting
pekit / Running pekit
pekit lint checks a recipe against a set of rules and reports every
violation in one pass. It is the mechanical half of a packaging review: the
things a person would otherwise verify by reading the recipe and running
readelf over the build, written down once and run everywhere.
pekit ships the rules but enables none of them by default. A
lint.pekit.toml file turns rules on and sets their parameters, so a
distribution decides what its recipes are held to and a standalone project
decides the same for itself. A tree with no lint file has nothing to lint,
and pekit lint says so (missing_lint_config).
Every rule is an error. There is no warning level: a rule is either on, or exempted for a stated reason. A warning would be a finding nobody has to justify.
Running it #
pekit lint
pekit lint --version 1.5.7
pekit workspace lint --json
With no version flag, an ordinary non-delegated recipe reads only its committed
recipe files, pekit.lock, and patch series. It resolves no source and touches
no network.
A delegated recipe is the deliberate exception. Plain pekit lint acquires
one source snapshot, loads the delegated pekit.toml, package files, and
lint.pekit.toml, merges them with the wrapper, and runs static rules against
that effective recipe. A templated, tracked-path, or PyPI source selects the
newest discoverable version; a fixed Git ref or fixed URL needs no synthetic
version; a local-only delegate uses its configured [source.local] tree. This
may contact the source and populate out_dir's source cache, but it runs no
target and needs no build stage.
With an explicit source/version flag (--version, --latest, --local),
lint performs the same effective-recipe checks and additionally checks the
payload each package would pack, read from an existing build stage. It
never builds: a missing stage is stage_missing, naming the pekit build to
run first. The rules that need a payload are marked in the tables below;
without an explicit selection they are skipped with a lint_skipped event
that says how many.
lint takes the version-selection and local-source flags, --env and
--keyring, and the global flags. It accepts no selectors and no --all:
every package a recipe defines is checked. pekit workspace lint fans out
across every member like any other command. A versioned run applies the
selected environment's [lint.allow]
exemptions, as the package gate does.
pekit package and pekit publish also run this policy, as the
lint gate: the static
rules against the recipe and the payload rules against the archives the run
just wrote. Any finding stops the run before publication.
Output is one lint event per finding, with rule, package and path
fields under --json, an event per exemption that applied (lint_allowed),
one per exemption that did not (lint_unused_allow), and a lint_summary.
The command fails with lint_failed when any finding stands.
The lint.pekit.toml file #
A rule is enabled by giving its key a value. A boolean rule takes true; a
rule with a real choice takes one of its enumerated strings; a rule with a
parameter takes the parameter. An absent key is a rule that does not run.
[]
= "reverse-dns"
= "spdx"
= true
= 80
[]
= true
= true
= true
[]
= true
= "full"
A rule's ID is its key path in the file: package.name.style,
elf.relro. That ID is what findings carry, what [allow] names and what
this page lists. Unknown keys are rejected (unknown_key), as is a value of
the wrong shape (invalid_value), so a misspelled rule cannot silently not
run.
Where files are found, and how they layer #
Files are found by walking up from the recipe directory to the workspace root. A recipe outside a workspace reads only its own, except that a delegated recipe also reads the file at its acquired source root. Plain lint performs that acquisition automatically; it is not necessary to request payload lint just to apply the source project's rules.
They merge outermost first, nearest winning per key, in this order:
- the delegated source root, when there is one;
- the workspace root;
- each directory down to the recipe directory;
- the recipe directory.
The source root comes first, not last, deliberately. A project may carry a lint file that holds itself to a higher bar than the distribution packaging it, but it cannot relax the distribution's rules.
A nearer file may re-parameterise a rule (description = 40 under a
workspace that says 80) but may not switch one off. Setting a rule
that an outer file enabled to false is refused (lint_rule_disabled).
Switching a rule off goes through [allow].
[allow]: exemptions with reasons #
For a payload exception limited to particular files, prefer allow_files:
[]
= "startup object linked into user programs"
Keys are case-sensitive, package-root-relative payload paths or glob patterns
(* matches within one path component; ** can cross directories). They match
destinations, not build-host source paths. Every entry requires a nonempty reason
and a known payload rule; absolute paths, parent traversal and malformed patterns
are rejected. Other files and other rules remain enforced. Exceptions apply to
all splits of the recipe, so keep paths narrow. Configurations merge per rule and
pattern, nearest winning for the same pair. Matching findings remain visible as
lint_allowed, and unused entries produce lint_unused_allow. A whole-rule
allow takes precedence; otherwise the first matching pattern in lexical order
is used. Neither form enables a rule that is disabled or absent.
Use a whole-rule exemption only when it genuinely applies to the entire recipe:
[]
= "non-public: grants unauthenticated SYSTEM access to a DWE guest"
= "upstream ships no manual pages; tracked in PEI-27"
An [allow] entry names a rule and gives a reason. The reason is mandatory
(missing_reason) and may not be empty. While the entry stands, that rule's
findings for the recipe are reported as lint_allowed rather than failing
the run. An entry that never applies is reported as lint_unused_allow, so
an exemption outlives its cause visibly and can be removed.
[allow] may appear in any file in the chain, but it belongs as close to
the recipe as the exemption is true for. Under --json, a workspace-wide
pekit workspace lint lists every exemption in the tree and its reason: the
review list of what is not yet up to standard and why.
Rules #
Each rule is general packaging hygiene, of the kind lintian, rpmlint, rpminspect or a Homebrew audit also checks. Anything distribution-specific is a parameter, never a value baked into pekit. Rules marked payload need a staged build and run only with a version flag.
[package] #
Checked for each package definition the recipe produces.
| Rule | Value | Checks |
|---|---|---|
package.name.style | "reverse-dns" or "lowercase" | Every package name, and the recipe directory when the recipe is a workspace member, matches the style. Reverse-DNS is org.example.thing; lowercase is any [a-z0-9._+-] name. |
package.license | true or "spdx" | A license is declared. With "spdx", the expression also parses as SPDX: identifiers joined by upper-case AND / OR, WITH for exceptions, balanced parentheses. The SPDX license list itself is not consulted. |
package.license_class | true | A peipkg-format package declares license_class. Other formats are not checked. |
package.homepage | true or "https" | A homepage is declared; with "https", it is an https URL. |
package.description | true or a length | A description exists, is one line, is at most the given length (80 when true), does not end with a full stop, and does not start with the package name or its last dotted label. |
package.dependencies | "consistent" | No self-dependency; no dependency on a name the package itself provides; no name both depended on and conflicted with; no name in both [dependencies] and [optional_dependencies]; no provide or conflict on the package's own name; no dependency, optional dependency or provide whose version spells out "{{version}}-N" by hand (use "= {{release}}"). |
package.references | "reverse-dns" | Every concrete package reference in package metadata and every Peipkg target dependency uses its canonical reverse-DNS name. Structured virtual capabilities such as pkgconfig(foo), python(abi) and ELF SONAMEs remain valid. Other deliberately unqualified interfaces must be listed in package.virtual_capabilities. |
package.architecture | "consistent" payload | A package declared architecture-independent ships no ELF object and nothing under an architecture-specific directory; a package declared for an architecture ships at least one such thing. The architecture-independent name is the package.noarch parameter, default noarch. |
Architecture-specific directory inference uses wildcard entries in
payload.dirs.lib, such as usr/lib/*-linux-*, and the split-debug directory
usr/lib/debug. Source text under usr/src/debug does not become
architecture-specific merely because of that directory name. ELF objects are
still detected there. Portable scripts and data may remain noarch when they
depend on native packages: those dependencies are resolved for the target
machine, not inherited as the portable package's architecture. Actual ELF or
architecture-specific directory content in a noarch package is still reported.
For an explicitly architecture-constrained source or header split, a required
architecture-specific sibling can justify that constraint. This payload check
cannot infer external dependency architectures; a genuinely architecture-tied
package without visible payload or sibling evidence needs a documented exception.
package.virtual_capabilities is an array parameter shared by the
package.references checks. It is for genuine interchangeable interfaces such
as sh or an operating-system role, never for a compatibility alias for a
concrete package. For example:
[]
= "reverse-dns"
= ["init", "sh"]
[source] #
Checked against the recipe's [source] table. Rules for a source kind the
recipe does not use do nothing.
| Rule | Value | Checks |
|---|---|---|
source.reproducible | true | The source is not [source.local] alone. A recipe with no source at all passes. |
source.discovery | true | A URL source has listing_url; a git source (other than a tracked path) has tag_regex. Without them new upstream releases cannot be found, and every update is by hand. |
source.versions.floor | true | A discovering source has a versions constraint with a lower bound: >=, >, =, ^ or ~. A < x ceiling alone, *, or no constraint follows any release upstream ever published, including ones older than the recipe. Ceilings are never required. |
source.versions.ceiling | "none" | No versions constraint bounds from above: no < or <= term, no = pin, no ^ or ~ range. A ceiling is where unattended updates silently stop; a new major is reviewed when it arrives, not fenced off in advance. |
source.ref | "immutable" | A git ref is a template (v{{version}}), a full commit hash, a refs/tags/ path or a tag-like v1.2, not a branch name that the lock could never pin. |
source.url.scheme | "https" | Every source URL (url, listing_url, signature and patch-series URLs) is https. A git URL may also be ssh://, git+ssh:// or user@host:path. |
source.lock | true | pekit.lock exists with at least one entry; every URL entry carries a sha256 (and each patch its own); every git entry a commit hash; and, when the recipe verifies signatures, every URL entry records the key that verified it. |
source.signature.required | true | A URL source has a [source.url.signature] block, and a git source (tracked paths included) a [source.git.signature] block. An upstream that publishes nothing verifiable is recorded as a reasoned allowance. |
source.signature.fingerprint | "full" | A signature block lists at least one fingerprint, and each is the full 40- or 64-hex-digit fingerprint, never a short key id. |
source.signature.keys | true | Every key_files entry exists and is not empty. |
source.patches.headers | true | Every patch in the series has, ahead of its first hunk, a non-empty Description: or Subject: line and an Origin:, Author: or From: line (DEP-3). git format-patch output satisfies this as written. |
source.patches.status | true | Every patch in the series records where it stands upstream: Forwarded: (a URL, no or not-needed), Applied-Upstream:, Bug:, or an Origin: that begins upstream or backport. |
[build] #
| Rule | Value | Checks |
|---|---|---|
build.dependencies.providers | array of provider names | Every build and test target declares a [build.<name>.dependencies.<provider>] (or [test.<name>.dependencies.<provider>]) table for each listed provider. An empty table is an explicit "none". |
build.test | true | The recipe defines a test target. A delegate recipe whose targets live in its source is not checked. |
[payload] #
All payload rules. Paths are payload destinations, slash-separated, relative to the package root.
Claim metadata uses canonical absolute paths in the installed logical root,
such as /usr/bin/sh. Staged and final-archive lint convert these to
package-relative keys when indexing claim slots; the leading slash does not
refer to the build host. Relative, root-only and noncanonical claim paths
(including .. components) are not valid claim-slot exemptions.
| Rule | Value | Checks |
|---|---|---|
payload.junk | true or an array of patterns | No destination matches a build-leftover pattern. true uses the built-in list: **/*.la, **/*.orig, **/*.rej, **/*.o, **/*~, **/*.swp, **/.git, **/.git/**, **/.gitignore, **/CMakeCache.txt, **/.DS_Store, usr/share/info/dir, **/perllocal.pod, **/.packlist. An array replaces it; "@default" in the array includes it. A .pyc without its .py is always reported. |
payload.scripts | true | A file with a #! line is executable; its interpreter is an absolute path that is shipped by this recipe or listed in payload.interpreters (for #!/usr/bin/env prog, both env's own path and prog must be allowed); and, for sh, dash, ash and bash, the host shell's -n syntax check passes. |
payload.symlinks.dangling | "forbidden" | Every symlink resolves to something shipped by one of the recipe's packages. Claim slot paths, which point across packages by design, are skipped. |
payload.symlinks.absolute | "forbidden" | No symlink has an absolute target. |
payload.filenames | "portable" | Every destination is printable ASCII with no whitespace. |
payload.special_files | "forbidden" | No device node, fifo or socket. |
payload.manpages.required | true | Every file in a bin directory has a manual page, shipped by any package of the recipe, named after it in any section. |
payload.manpages.compression | "gzip" or "none" | Every file under a man directory is gzip-compressed, or none is. |
payload.pkgconfig | true | No .pc file contains the build directory path. |
payload.license_file | a path template | Every package ships a regular file under the rendered directory. {{name}} is the package name; version tokens render as in a recipe. |
Parameters, all optional: payload.interpreters (default /bin/sh,
/usr/bin/sh, /bin/bash, /usr/bin/bash, /usr/bin/env,
/usr/bin/python3, /usr/bin/perl), payload.dirs.bin (default bin,
sbin, usr/bin, usr/sbin), payload.dirs.lib (default lib, lib64,
usr/lib, usr/lib64, lib/*-linux-*, usr/lib/*-linux-*) and
payload.dirs.man (default usr/share/man, share/man). Directory
parameters are glob patterns matched against a destination's directory.
[split] #
| Rule | Value | Checks |
|---|---|---|
split.devel.packages | a name pattern, or an array | payload. Development files land only in packages whose names match: headers, pkg-config files, CMake files, aclocal macros (the split.devel.files patterns, default usr/include/**, include/**, **/pkgconfig/*.pc, **/cmake/**, usr/share/aclocal/**, usr/lib/**/*.cmake) and unversioned .so symlinks in a lib directory. After ten findings in one package the rest are counted. |
Empty directories owned by a filesystem skeleton are not development files. The split check applies to the files and symlinks that provide the interface.
[elf] #
All payload rules, checked for every ELF program or shared library in
every package. Relocatable objects, kernel modules and anything under
usr/lib/debug/ or usr/src/ are skipped.
| Rule | Value | Checks |
|---|---|---|
elf.pie | true | No ET_EXEC program; executables are position-independent so address-space randomisation applies. |
elf.stack | "non-exec" | A PT_GNU_STACK header is present and does not request an executable stack. |
elf.relro | "partial" or "full" | A dynamic object has a PT_GNU_RELRO segment; with "full", it is also bound at load (BIND_NOW), so the GOT is read-only afterwards. |
elf.relr | true | A dynamic object with relative relocations left in .rela.dyn has none, or carries DT_RELR; the linker flag is -z pack-relative-relocs. |
elf.cet | true | An x86 object carries both IBT and SHSTK GNU properties, and inspected indirect-call entry points begin with ENDBR32/ENDBR64. Requires local symbols or matching build-ID debuginfo. Other architectures are not checked. |
elf.rpath | "forbidden" | No DT_RPATH or DT_RUNPATH. |
elf.textrel | "forbidden" | No text relocations. |
elf.stripped | true | No .debug_* sections and no .symtab. |
elf.debuginfo | a package-name template | The recipe has a package named by the template ({{name}} is the shipping package) and, for every object with a build-id, that package ships usr/lib/debug/.build-id/xx/yyyy.debug. An object without a build-id is reported. |
elf.build_paths | "forbidden" | The object does not embed the build directory path anywhere in its bytes. |
elf.soname | true | A lib*.so* regular file directly in a lib directory has a DT_SONAME, and its file name is the soname or the soname followed by more version components. |
What lint does not check #
Whether a package's role is documented, whether its split is sensible, whether a service runs under the right identity, and whether its files sit in the places a particular distribution mandates. The first three are review judgement; the last is pack-time validation in the package format, not a lint rule.
What CET validation establishes #
elf.cet checks the runtime payload's machine code, using exported function
symbols, the main callback passed to libc, and relocated pointers to known
functions in loaded data. It supports x86 REL, RELA and packed RELR relocations.
For stripped objects it obtains symbols from a debug file shipped by this
recipe at usr/lib/debug/.build-id/xx/yyyy.debug, and verifies the build ID,
machine and ELF class before using its addresses. The instruction bytes always
come from the runtime payload. Missing symbols, unreadable evidence and objects
with no supported targets produce findings rather than a reduced-coverage pass.
Functions reached only by direct calls, jump-table labels and the process entry point do not automatically require ENDBR. This check is bounded static evidence: it cannot enumerate computed or generated targets, prove shadow-stack compatibility, validate all inline assembly, or establish that a running system actually enables CET. Retain upstream tests and runtime enforcement tests where these behaviors matter.
Compile the code with the relevant protection option, including linked runtime
and assembly inputs. GNU ld's -z ibt and -z shstk force compatibility markers;
they cannot substitute for instrumentation. -z ibtplt generates IBT PLT entries
without overriding the input-note merge. On a native toolchain that provides
marked inputs, use -z cet-report=error to reject missing properties during
linking. Do not combine that check with forced properties: forcing suppresses
the corresponding missing-property diagnostic. See the
GNU linker options.
Rust uses its own control-flow protection code-generation option.
Its linked standard library must also be compatible. Older dependencies may
probe unstable features in build scripts: enabling RUSTC_BOOTSTRAP globally
can change those probes. A recipe that requires an unstable code-generation
option should confine that access to actual compiler code generation and test
the resulting executable, as well as the complete linked input set.
If a supported upstream/toolchain combination cannot meet the rule, document a
narrow allow_files."elf.cet" exception with the affected payload path, reason
and removal condition. Keep the resulting lack of compatibility visible; do
not manufacture a note to obtain a passing lint result.
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 | Managed output directory strictly below the recipe root. Relative paths resolve from the recipe root; an absolute path is accepted only when it remains below that 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]. |
input | table | no | Additional authenticated upstream inputs, keyed by name. See [input.<name>]. |
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]. |
tags | array of strings | no | Labels for selecting this recipe as a workspace member with --tag and --exclude-tag. Each tag is a selector-style name; duplicates are rejected. Only the member recipe's own tags count: a delegated source's tags are ignored. See Workspaces. |
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 four sub-tables. At most one
reproducible source (git, url, or pypi) may be present — declaring
more than one 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.pypi] | Discover and download a standardized PyPI source distribution. |
[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, url, and PyPI 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 filtering tags during enumeration. A named version capture supplies the complete version; named major, minor, and patch captures compose a dotted version. Optional revision (fourth numeric component), suffix (unseparated), prerelease, and buildmeta captures may extend it. Unnamed captures have no extraction semantics. |
tracked_path | string | no | Track one repository-relative regular file on a fixed moving ref. Versions are synthesized from changed blob observations and matching lock history. Requires a non-templated ref; incompatible with tag_regex. See Tracking one file on a moving ref. |
signature | table | no | Upstream release-signature verification. See [source.git.signature] below. Incompatible with tracked_path. |
[source.git.signature] #
Optional sub-table of [source.git]. If present, every resolve requires a
valid OpenPGP signature on the release. The signature is checked after the
mirror fetch and before the lock is written or the tree is checked out. An
unsigned or lightweight tag, or an unsigned commit, is signature_missing. An
SSH or X.509 signature is signature_unsupported. A signature that fails, or a
tag object that names another tag or points somewhere other than the resolved
commit, is signature_invalid. Each of these aborts the run and writes no lock
entry. The verified signer's fingerprint is recorded as the lock entry's
signature_key.
| Key | Type | Required | Meaning |
|---|---|---|---|
object | string | no | What carries the signature: "tag" (the rendered ref is an annotated, signed tag naming the resolved commit; default) or "commit" (the resolved commit has a gpgsig header). Any other value is invalid_signature. |
key_files | string array | yes | Pinned public keys (armored or binary, one or more per file), relative to the recipe directory. An empty or absent list is missing_key. |
fingerprints | string array | no | Allowlist of signer primary-key fingerprints, as for [source.url.signature]. A valid signature by any other pinned key is signature_untrusted_key. |
ignore_expiry | bool | no | Permit a signature made after the pinned signing key expired, as for [source.url.signature]. Default false. |
[source.url] #
| Key | Type | Required | Meaning |
|---|---|---|---|
url | string | yes | URL to download. Templated with the version. |
listing_url | string | no | Explicit URL to fetch when enumerating versions. Use when releases are listed separately from their download paths; default is derived from url. |
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 ".". |
exclude | string array | no | Exact files or directory subtrees, relative to root, that extraction never creates. Canonical relative paths only: no .., trailing slash, backslash, glob or template syntax. A nonempty list requires extract = true. See Archive extraction. |
versions | string | no | Version cap: a constraint string filtering enumerated or requested versions (see Versions). |
file_regex | string | no | Regex extracting versions when enumerating from a listing. Named version, major, minor, patch, revision, suffix, prerelease, and buildmeta captures map filenames with the same semantics as Git tag_regex; unnamed captures only filter. |
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. |
patch_series | table | no | Incremental upstream patch series layered over each major.minor base archive — see [source.url.patch_series] below. |
When patch_series is present, the selected version must be stable
major.minor.patch. The primary url, root, checksum-table lookup, and
signature render against major.minor; the complete selected version remains
the package version.
[source.pypi] #
| Key | Type | Required | Meaning |
|---|---|---|---|
project | string | yes | PyPI project name. Must start and end with an ASCII letter or digit and otherwise contain only letters, digits, ., _, or -. |
artifact | string | yes | Distribution kind. Currently must be exactly "sdist". |
versions | string | no | Version cap: a constraint string filtering enumerated or requested versions (see Versions). |
Pekit requests https://pypi.org/simple/<normalized-project>/ as
application/vnd.pypi.simple.v1+json. It accepts one non-yanked, stable,
Pekit-compatible, PEP 625 .tar.gz sdist per version, requires that entry's
SHA-256, extracts its standardized root, and records the exact URL and digest
in pekit.lock. Wheels and legacy sdist formats are not candidates. There is
no configurable index URL in this source kind.
[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 expiry is evaluated at the signature's signed creation time, so a later key expiry does not invalidate an older release. Current key revocations and explicit signature expiry are still enforced, and a signature dated after key expiry or materially in the future is rejected by default.
| 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 (yes with of = "checksums") | Signature URL template — the checksum manifest's location with of = "checksums". {{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), "decompressed" (its decompressed content — kernel.org's .tar.sign signs the uncompressed tar), or "checksums" (a clear-signed checksum manifest at url that must list the artifact exactly once; see Signed checksum manifests). Any other value is invalid_signature. |
fingerprints | string array | no (yes with of = "checksums", full fingerprints only) | 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. |
ignore_expiry | bool | no | Permit a new signature made after the pinned signing key expired. Default false. This bypasses key expiry only; cryptographic verification, fingerprint allowlisting, present-time revocation, explicit signature expiry, and timestamp checks remain enforced. |
[source.url.patch_series] #
Optional sub-table of [source.url] for upstreams that publish a base archive
and numbered incremental patches. Version major.minor.N materialises the
base plus patches 1 through N. Enumeration always exposes patchlevel zero and
every contiguous published patchlevel; a gap is url_patch_gap.
| Key | Type | Required | Meaning |
|---|---|---|---|
url | string | yes | Patch artifact URL template. Must contain {{patch}}; {{major}}, {{minor}}, and other version variables are available. |
file_regex | string | no | Regex used against the patch-directory listing. Must contain a named patch capture. When omitted, Pekit derives the matcher from url. |
patch_width | integer | no | Zero-pad {{patch}} to this width. Default 0 (no padding). |
strip | integer | no | Non-negative path-component count passed to patch -p. Default 0. |
signature | table | no | Per-patch detached-signature verification. Uses the [source.url.signature] schema and defaults its URL to {{source_url}}.sig. |
Patches are applied in order using the host patch command with batch mode,
forward-only application, and fuzz disabled. All patch hashes and configured
signatures are verified before Pekit writes the version's lock entry.
[source.local] #
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | Path to the source directory, resolved relative to the recipe root. |
[input.<name>] #
An additional authenticated upstream, for a build that genuinely consumes more
than one. A recipe may declare any number, each under its own name. Names are
lower-case alphanumeric words separated by hyphens (invalid_name otherwise),
and must stay distinct after upper-casing and mapping - to _, because that
is the $PEKIT_INPUT_<NAME> a target reads.
Accepts the same keys as [source.url], including a nested
signature table, with two differences:
| Difference | Meaning |
|---|---|
versions | Must pin exactly one version — "= 7.0.9" or a bare "7.0.9". A range is invalid_versions: inputs are never selected by --latest or --all-versions. |
patch_series | Not accepted (unknown_key). A patch series applies to the source tree. |
{{version}} inside an input block renders that input's own version, never the
recipe's. Each input is fetched, signature-checked and pinned in
pekit.lock as an [[input]]
entry, materialised at $PEKIT_INPUT_<NAME>, bind-mounted read-only under
isolation, and carried in the source package under upstream/<name>/.
When a recipe delegates build, the fetched source tree's own [input.<name>]
tables are part of the build too. The delegating recipe resolves and pins them
in its own pekit.lock, and their key_files resolve beside the source's
pekit.toml. A recipe input with the same name replaces the source's as a
whole. See Additional inputs.
[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, together with the [input.<name>] tables the source declares for them. |
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 controls naming, emission and
additional shared source inputs. A recipe emits one when both of these hold: it has a reproducible
[source] (git, url or PyPI; 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. |
workspace_inputs | array of relative paths | no | Additional shared workspace inputs for this recipe. Under workspace isolation they are copied into read-only worker mounts and included byte-for-byte in the source bundle. Missing, ignored or escaping inputs fail. |
license | string | no | SPDX expression covering material that only the source package carries, such as upstream test programs or build helpers that no member ships. It is added to the members' licenses, never substituted for them. An expression that isn't valid SPDX is invalid_value. |
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 license (see below; and classed as the most encumbered member's class —
proprietary over firmware over unknown over free, with an
undeclared member counting as unknown), and installs under /usr/src/dist/<name>-<version>/
(with any -source suffix stripped from <name>).
The conjunction is built term by term. Each expression is split into its
top-level AND terms. A term containing OR keeps its parentheses, so the
surrounding ANDs can't rebind it. Repeated terms appear once, and the terms
are sorted. Members licensed GPL-2.0-or-later OR LGPL-3.0-or-later,
GPL-3.0-or-later and GPL-3.0-or-later AND LGPL-2.1-or-later produce
(GPL-2.0-or-later OR LGPL-3.0-or-later) AND GPL-3.0-or-later AND LGPL-2.1-or-later.
The package's content:
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.source/— the prepared tree, with patches already applied. Large trees travel inprepared-source.tarinstead;rebuild.pyverifies and reconstructssource/before invoking Pekit.workspace/— the complete captured recipe, shared inputs, inherited policy and environment profiles, preserving their relative paths.recipe/andpatches/— compatibility views.acquisition/andbuild-environment/— captured vendoring outputs and dependency identities when provided by the isolated job.build-inputs.json,rebuild.py,REBUILD.md— schema-2 or schema-3 hashes, modes, provenance and the outside-checkout reconstruction command.
Output/cache state, ignored developer files, keyring/credential files and configured key paths are excluded. Missing or escaping source links fail closed. See source reconstruction for required dependency services, signing keys and the bounds of reconstruction.
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] #
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. |
keyring_inputs | string array | no | Dotted keyring entries requested by this target. Default none. The operator keyring must grant public access, or acquisition for build.vendor. Signing entries cannot be exported. |
dependencies | table | no | Build and test targets. Dependencies the target needs provisioned in its root. Under a peipkg provider the composed root holds only what is declared — a test stage that shells out needs dash (pekit runs command through /usr/bin/sh) and whatever else its script calls. See below. |
gate | bool | no | Test targets only. When true, package and publish run this test after staging its needs and before writing any artifact. Default false; --no-gates explicitly bypasses gated tests and the lint gate. |
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. sign is
accepted only in [build] targets, gate only in [test], and dependencies
in [build] and [test]; using them elsewhere 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] #
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. pip produces Peios binary/firmware signatures; module signs .ko and .ko.zst with a kernel PKCS#7 trailer. Other names are 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] #
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". |
dependency_provider | string | no | The dependency namespace this package's runtime [dependencies] belong to, matching an env file's dependency_provider. Workspace ordering follows a package's runtime dependencies only when this equals the selected env's provider. Inherited through package layers like format, so a workspace sets it once in its shared package.pekit.toml. Undeclared means the runtime dependencies do not affect ordering. |
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. "*" means every version, as it does in dependencies; the manifest records such an entry with no constraint. |
side_effects | array of strings | no | Declared install-time side effects. |
sd_overrides | table | no | payload path → SDDL security descriptor. See [sd_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. After
[package].version is rendered, {{release}} is also available: the package's
own full version, including its revision (1.13.2-3 for
version = "{{version}}-3"). Pin packages built from the same source to each
other with "= {{release}}", so the pins follow the revision automatically; the
package.dependencies lint rule reports relations that spell out
"{{version}}-N" by hand. 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, where the rendered value must be a peipkg version — [epoch:]upstream-revision (PSPU book 5 §5.5), so "{{version}}-1" and never a bare "{{version}}". A value that does not parse is an invalid_package_version error when the package is expanded, before anything is built. |
architecture | string | no | Target architecture. Required for peipkg. |
description | string | no | Human-readable description. |
license | string | no | License identifier. Required for peipkg. |
license_class | string | no | Licence class: unknown, free, firmware or proprietary (PSPU book 5 §18). Any other value is an invalid_license_class error. Absent means unknown; set it whenever license carries a LicenseRef- term, which is exactly when the expression alone cannot say. |
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 published. localdir copies an artifact by
basename and is 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. |
peipkg publishes peipkg-format artifacts into a complete signed repository
and is written as one table:
[]
= "dist/repository"
= "experimental"
= "keyring:signing.repository_key"
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | Repository state directory, relative to the workspace root (or recipe root outside a workspace). |
name | string | no | Name written into a newly initialized repository's signed descriptor. Defaults to the final component of path; it does not alter an existing repository. |
signing_key | string | yes | Repository metadata signing key. A normal value is a key-file path; keyring:<dotted.entry> reads the path from that keyring leaf. |
If the directory named by path is absent or empty, it is initialized before
the first package batch is published. A non-empty directory that is not already
publisher state is refused rather than overwritten. Only peipkg-format
artifacts may use this target. See
Publish targets for key
resolution, batching, and repository semantics.
[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.
[sd_overrides] #
Each key is a payload path — exactly as it appears in the package, with no leading slash — and each value is an SDDL string. pekit compiles it to a binary security descriptor at pack time and puts the result in the manifest.
[]
= "O:SYG:SYD:P(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;;FX;;;WD)"
An entry with no override gets its descriptor by inheritance from its parent directory when the consumer creates it, and that is what almost every entry should get. Declare one where a path needs to be more restrictive than its parent would make it, needs access for a principal the parent's inheritable ACEs do not name, or needs to start a new inheritance scope of its own.
Three constraints are worth knowing before writing one:
- The path must name a real payload entry, and it must be a regular file or a directory — never a symlink, which carries no descriptor of its own. A consumer rejects the whole package otherwise.
- Absolute SID aliases only. The compile happens on a build host
with no domain and no machine to resolve against, so
SY,BA,CO,WDand literal SIDs are fine while domain- and machine-relative aliases (DA,EA,LAand the rest) are an error. - A rights field may end in a hex literal.
FRFX,0x1200afandFRFX0x6all parse, the last of them as file read, file execute and the two bits after that. Use it for rights with no two-letter mnemonic —FILE_ADD_FILEandFILE_ADD_SUBDIRECTORY, in particular — rather than writing the whole mask as hex and losing what the mnemonics said. The literal consumes the rest of the field, so it can only come last.
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. |
[isolation] | table | no | enabled (bool, default false) requires workspace sandbox policy; inputs (array of relative paths) grants copied, read-only shared workspace inputs, also captured in source bundles. |
[source_package] | table | no | inputs (array of canonical relative paths) captures coordinator-side support source, such as root-preparation scripts, without mounting it into workers. Workspace configuration, package/lint policy and environment profiles are captured automatically. |
[policy] #
The [policy] table accepts package-version floors and exact ELF capabilities.
Unknown keys are rejected.
| Key | Type | Required | Meaning |
|---|---|---|---|
symbol_capabilities | array of strings | no | SONAMEs whose exact GNU ELF version nodes become elfver(soname:token) dependencies and provides, independently of package versions. |
[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_":
[]
= ["libstdc++.so.6"]
[]
= "GLIBC_"
= "GCC_"
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], [sandbox], or
dependency_provider; a file with none of them is rejected (missing_key).
| Key | Type | Required | Meaning |
|---|---|---|---|
[env] | table | one of the four | Environment variables. See env table. |
[wrap] | table | one of the four | Command wrapper. See wrap table. |
dependency_provider | string | one of the four | 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. |
[lint.allow] | table (rule → reason) | no | Whole lint rules exempted for artifacts built in this environment. See below. |
[sandbox] #
A workspace profile's command (string or argv array, required) prepares the
fresh PEKIT_SANDBOX_ROOT destination. No {{command}} placeholder is used.
Optional network_targets is an array containing only "build:vendor".
Optional entry is an argv array whose first element is a clean absolute path
inside the prepared root. Pekit runs it inside the sandbox ahead of every target
command of the profile, passing the target's shell invocation as its remaining
arguments; the entry must exec them. The preparer must supply the program as
an executable regular file, or the job fails with sandbox_entry before the
worker starts. Processes the entry leaves behind end with the job.
Pekit owns process isolation; the preparer only supplies dependencies. This table
requires [isolation] enabled = true in the workspace. Member/source profiles
cannot override it. See isolated production jobs.
If a prepared root has a missing or empty /etc/hosts, pekit supplies fixed
IPv4 and IPv6 localhost entries before starting the worker. A populated regular
hosts file is left intact; a symlink is replaced without following its target.
Offline loopback tests therefore work without importing the host's aliases or
granting network access.
[lint.allow] #
Some lint findings are a property of the build environment rather than of any recipe: a foreign toolchain whose startup objects carry no CET notes, say. An env file names those rules, each with its reason:
[]
= "Debian startup/runtime objects carry no CET markers; native builds enforce the rule"
The exemptions apply to the package lint gate
and to a versioned pekit lint run under that --env, and to nothing built in
another environment. Each key must be a known rule — not a parameter — and each
reason must be non-empty. Both the workspace's and the recipe's selected env
file contribute. An exemption a lint.pekit.toml already states keeps that
file's reason, and allowed findings are reported like any other exemption.
Use this only for an environment's own limitation; a package's defects belong
in its recipe's lint file, or fixed.
--env <name> selection #
The active env file is chosen from the --env <name> flag (default main),
resolved at the workspace root and then the recipe root:
--env value | File loaded at each root | Must exist? |
|---|---|---|
(unset) or main | env.pekit.toml | no (silently absent at either root) |
none | (no env file loaded) | n/a |
any other <name> | <name>.env.pekit.toml | yes — in at least one root |
When both files exist, the workspace file is the base and the recipe file
overlays it for [env], [wrap], and dependency_provider. Outside a
workspace only the recipe root is searched. The default env.pekit.toml is
optional everywhere. When both paths resolve to the same underlying file, it is
loaded only once.
[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 → workspace env file → (delegated source) → recipe → 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 coordinator values and explicitly granted worker inputs. 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 or { value = "...", access = "..." } entries.
Requested worker inputs use an environment-variable name derived from their path:
- 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 | A string (coordinator-only), or a typed table with string value and access. |
| Nesting | Sub-tables nest arbitrarily; each nesting level adds a segment to the exported name. |
| Typed access | public permits requesting targets; acquisition permits only build.vendor; signing is coordinator-only. path and content forms remain unsupported. |
| Collisions | A keyring export that collides with a normal or managed env variable is an error (env_collision). |
Well-known entries #
Targets receive only entries listed in keyring_inputs and permitted by their
access grant. Inline overrides are coordinator-only. The following entry is 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 private-key entries are never exported to workers.
lint.pekit.toml #
Enables and parameterises the rules pekit lint checks. pekit turns no rule
on by itself; a tree with no lint file has nothing to lint.
| Key | Type | Required | Meaning |
|---|---|---|---|
[package], [source], [build], [payload], [split], [elf] | tables | no | Rule keys and parameters, one table per area. A key's dotted path is the rule's ID. Every key must be a known rule or parameter (unknown_key) with a value of the shape it accepts (invalid_value). |
[allow] | table (rule ID → reason) | no | Exemptions. Each key names a rule; the value is a non-empty reason (missing_reason). A parameter key is not a rule and is rejected. |
Files are found from the recipe directory up to the workspace root, plus a
delegated recipe's source root, and merge outermost first with the nearest
file winning per key. A nearer file may not set a rule an outer file enabled
to false (lint_rule_disabled); it exempts it through [allow] instead.
Every rule, its accepted values and the merge order are on
Linting.
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 ordinary git or URL entry. Tracked-path
git history is append-only, so changed bytes get a new date version and
--repin is rejected. 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. |
[[input]] | array of tables | One entry per locked version of each [input.<name>], kept sorted by name and then 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 and PyPI sources | The rendered or selected URL fetched at lock time. Provenance only — the hash, not the address, is the assertion. |
sha256 | url and PyPI 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. |
repository | tracked-path git sources | Git repository URL bound by the snapshot. |
path | tracked-path git sources | Fixed repository-relative regular file selected by the recipe. |
blob | tracked-path git sources | Git blob object ID reached by commit:path. |
blob_sha256 | tracked-path git sources | SHA-256 of the exact blob bytes; the transport-independent content assertion. |
signature_key | url sources with [source.url.signature]; git sources with [source.git.signature] | Hex fingerprint of the pinned upstream key that verified the artifact or release at lock time. A git entry locked before its recipe gained the signature block records it on the next resolve. |
[[source.patch]] | url sources with [source.url.patch_series] | Ordered patch inputs applied for this version. Each carries url, sha256, and optional signature_key with the same meanings as the base artifact fields. |
locked_at | all | UTC timestamp of the pinning run (RFC 3339). |
Each [[input]] entry is addressed by name and version, and asserts what a url
[[source]] entry does. The entries include inputs declared by a delegated
source tree, which are pinned here, in the delegating recipe's lock:
| Key | Meaning |
|---|---|
name | The input's name, matching its [input.<name>] table. |
version | The version its versions constraint pins. Moving the constraint adds an entry the first time the new version resolves. Existing entries stay, so builds that still name an older version keep verifying against it. |
url | The rendered URL fetched at lock time. Provenance only. |
sha256 | SHA-256 of the fetched artifact — the assertion. |
signature_key | Hex fingerprint of the pinned upstream key that verified it, when a signature block is configured. |
locked_at | 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"
[[]]
= "https://example.org/dash-0.5-patches/dash05-001"
= "9b44d9f7a36d1a20537817f92da72bf10eb81f66df860f23298b2d4e830a6d8d"
= "9f0b7ddc1325a3e2c40e5f0a88b1c62f3d94ab07"
[[]]
= "1.2.0"
= "v1.2.0"
= "8f3a1bc99e2f6d41c07b21ac1886d63b7f2e5d10"
= "2026-08-10T11:00:00Z"
[[]]
= "2026.09.06.2"
= "https://github.com/example/upstream.git"
= "refs/heads/release"
= "security/trust/certdata.txt"
= "6b839fbb82801a7a5f5e37df0864c149fddda2b7"
= "65fddc0b380650ee16aa47e4e28e3a5e2c95f201"
= "fa5092f93c2d5352ebf6c9f4f26f4232a2e2bc0fd8a8950756c1984984b490e0"
= "2026-09-06T12: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. |
{{suffix}} | unseparated upstream suffix | Empty string when absent. |
{{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. |
{{release}} | the package's own rendered version, including its revision (1.13.2-3) | Only available in a package definition's fields after [package].version has been rendered, such as dependencies, provides and conflicts. Errors anywhere else, including in version itself. |
A version string has one or more dot-separated numeric components, an optional
unseparated alphanumeric suffix beginning with a letter (for example 2026c),
and optional -prerelease and +buildmeta tails. {{version}} preserves the
full string. {{major}}, {{minor}}, and {{patch}} expose the first three
numeric components for compatibility, so {{minor}} / {{patch}} are only
present when the version actually carries them; fourth and later components
are available through {{version}} only.
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 |
{{suffix}} | $PEKIT_VERSION_SUFFIX |
{{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 sends a package's freshly built artifact to its declared
destinations. There are two publish kinds: localdir and peipkg. Any other
[publish.*] key is rejected (unknown_key). A package selected for publishing
that declares no target is an error.
localdir #
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}}, where {{version}} is the rendered package version (1.26.2-3), not the upstream one. The upstream-version tokens ({{major}}, {{prerelease}} and the rest) do not describe a package version and are refused with a diagnostic. 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. |
peipkg #
The singular [publish.peipkg] target publishes all peipkg artifacts selected
for one package/version plan that name the same directory as one batch through
the Peipkg repository publisher:
[]
= "dist/repository"
= "experimental"
= "keyring:signing.repository_key"
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | Repository state directory, relative to the base root. It is not package-instance templated. |
name | string | no | Stable repository name used only when initializing path. Defaults to the final path component. An existing repository's signed descriptor is authoritative. |
signing_key | string | yes | Ed25519 private key used to sign the repository indexes. A bare value is a key-file path. keyring:<dotted.entry> reads a key-file path from that keyring leaf. |
A direct relative signing_key path resolves from the base root. A path read
from a keyring resolves from the invocation's working directory, like
signing.package_key. Both raw 32-byte Ed25519 seeds and PKCS#8 PEM files are
accepted.
If the directory named by path is absent or empty, Pekit initializes a
complete repository there: signed descriptor, public keys, and empty active and
archive indexes. The repository metadata key becomes a trust key. When the
freshly built packages use a different signing.package_key, its public half
is also recorded as a trusted key, so consumers can verify those packages. An
existing repository is never reinitialized or given new trust implicitly; its
descriptor must already trust both the metadata-signing key and the
package-signing key.
Pekit then publishes that batch into the repository's canonical
p/<name>/<version>/ tree and regenerates both signed indexes once for the
batch. The batch is published only after every selected package of the
recipe has been written and has passed the lint gate: if any of them fails,
none is published, so a partly failed publish --all cannot leave a family at
mixed revisions. Separate selected versions and workspace members are
serialized but remain separate repository publications, so a workspace run
that stops at a failing member keeps the members already published before it. The target rejects non-peipkg
artifacts and already-published name/version/architecture identities. Workspace
members sharing one repository are serialized so their whole-index updates
cannot overwrite each other.
The directory is the repository and can be served or copied verbatim. This target does not upload it to remote storage; combine deployment with a separate step when the serving tree is not locally mounted.
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, lint, workspace, help, or version. 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 -.
Help and version #
| Invocation | Output |
|---|---|
pekit help, pekit --help, pekit -h | The overview: every command with a line each, the global flags, and a pointer to this page. Exit 0. |
pekit help <command>, pekit <command> --help | That command's usage line, what its selectors mean, and its flags. Generated from the same tables the parser uses, so it cannot list a flag the command rejects. Exit 0. |
pekit (no arguments) | The overview on stderr, exit 1 (missing_command): nothing ran. |
pekit version | pekit <module version> (<commit>[, modified]), from the binary's build info. --version and -V are the version selector, so the print form is a command. |
--help wins over validation: pekit clean --version 1 --help prints help
rather than unsupported_flag. help and version accept the global flags
and no others; workspace cannot delegate to either.
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. An existing local path takes precedence over remote-locator spelling such as a .git suffix. |
--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. |
--locked | No-value | Make --latest, --all-versions or a version constraint select from the versions already in the recipe's pekit.lock instead of discovering them upstream. Opt-in; see Selecting from the lock. |
--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, and a stage left behind by a target that did not finish is refused with stage_incomplete rather than reused. |
--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. |
--no-gates | No-value | package/publish only: skip every gate = true test in the recipe and the lint gate, for rapid iteration. Pekit warns with the skipped gate names. |
--strict | No-value | build/test/package/publish only: require a committed catalogue (other than pekit.lock), a source pinned in pekit.lock, and no bypass flag (strict_bypass, strict_dirty, strict_provenance). On publish, also check every install and upgrade closure of the resulting repository. See Strict mode. |
--env <name> | Required-value | Select environment-file layers. main (default) loads optional env.pekit.toml; <name> loads <name>.env.pekit.toml, which must exist at the workspace or recipe root; a recipe file overlays the workspace file. none loads no env files. |
--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). |
--replace | No-value | publish only: let a peipkg package overwrite an already-published package with the same name, version and architecture, instead of refusing it. Each overwritten package is reported with its old digest. This breaks the repository's promise that a published version never changes, so use it only on a repository nobody consumes yet, such as one being bootstrapped. --strict refuses it. |
--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. |
--journal <file>, --journal=<file> | Required-value, path | Record each member that succeeds; repeating the same run skips them. Creating <file>.stop stops the run between members (workspace_stopped). A journal from a different run is journal_mismatch. See Resumable runs. |
Two member-selection flags follow the delegated command, with its own flags.
Outside workspace they are an error (invalid_flags).
| Flag | Value form | Meaning |
|---|---|---|
--tag <tag> | Required-value, repeatable | Run only members whose recipe tags include any given tag. |
--exclude-tag <tag> | Required-value, repeatable | Skip members whose recipe tags include any given tag. Applied after --tag. |
A selection that matches no member fails with empty_workspace.
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 | --no-gates | --env | --keyring | --refresh-source | --allow-unanchored | --allow-unsigned | --replace | --all | clean mode | --repin | --strict |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
build | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | |||||||
test | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | |||||||
install | Yes | Yes | Yes | Yes | Yes | Yes | Yes | ||||||||
package | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | |||||
publish | Yes | Yes | Yes | 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 | ||||||||||||
lint | Yes | Yes | Yes | Yes | |||||||||||
help | |||||||||||||||
version |
"clean mode" is the --output-only / --target-only pair. Plain lint
acquires delegated recipe files automatically. Its version-selection and
local-source flags make that source choice explicit and additionally select an
existing build stage for payload rules; it never
builds.
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 |
--locked without --latest, --all-versions or a version constraint, or on lock | invalid_flags |
--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) |
lint with any selector | invalid_selector (lint checks every package) |
lint --all | unsupported_flag |
help <name> where <name> is not a command | unknown_command |
version with any selector | invalid_selector |
workspace help or workspace version | missing_workspace_command |
--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 | The source's own date, Unix seconds: a Git source's commit time; a URL or PyPI source's newest archive member (fixed by the locked bytes), or the recipe's last commit when the download is not a tar archive; a local tree's, or a sourceless recipe's, last commit touching that directory. 0 when none is available (a local tree outside Git takes the run start instead). |
SOURCE_DATE_EPOCH | The same value, under the reproducible-builds name that compilers, documentation generators and archivers read, so they stamp the release rather than the build. Not set when PEKIT_SOURCE_TIMESTAMP is 0. A recipe or workspace [env] may override it. |
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 in an isolated workspace #
When the workspace enables isolation, the profile's root preparer and the sandboxed target see a few more variables.
| Variable | Seen by | Value |
|---|---|---|
PEKIT_SANDBOX_ROOT | Root preparer | The fresh directory the preparer populates as the target's root. |
PEKIT_JOB_STATE | Root preparer | Coordinator-only storage for the job, never mounted into the sandbox. |
PEKIT_NATIVE_ROOT | Target | 1 when the profile's dependency provider is peipkg and the target has no network access; unset otherwise. A recipe built by more than one provider can use it to tell the composed native root apart. |
Set only when a version is selected #
Exported only when the run resolves a non-empty version.
| Variable | Value |
|---|---|
PEKIT_VERSION | Full version string, including fourth and later numeric components. |
PEKIT_VERSION_MAJOR | First numeric component. |
PEKIT_VERSION_MINOR | Second numeric component. |
PEKIT_VERSION_PATCH | Third numeric component. |
PEKIT_VERSION_SUFFIX | Unseparated upstream suffix (may be empty). |
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.