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

pekit

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

Single-page view · as markdown

What is pekit

pekit / Getting started

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

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

The one idea: plan before execute #

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

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

The pipeline #

A single invocation runs through a fixed sequence:

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

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

The shape of a recipe #

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

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

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

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

The commands #

Ten commands, each with a defined job:

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

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

Where pekit fits #

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

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

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

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

Important

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

What to know before you start #

A few facts about the tool as it ships today:

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

Where to start #

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

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

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

Quick start

pekit / Getting started

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

Prerequisites #

  • A pekit binary on your PATH. Pekit is a single binary built from its source repository with go build ./cmd/pekit; there is no separate installer. pekit help lists the commands and pekit help <command> a command's flags; these docs are the full manual.
  • A POSIX shell (sh). Target commands run under sh -euc; any Linux system has this.
  • zstd and tar, for the final inspection step only.

Create the recipe #

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

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

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

Two things are going on here:

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

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

Run the build #

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

$ pekit build

You should see three timestamped lines, ending in success:

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

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

Inspect the staged output #

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

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

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

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

Describe the package #

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

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

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

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

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

Write the package #

$ pekit package

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

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

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

Look inside #

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

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

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

Where to go next #

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

Anatomy of a recipe

pekit / Writing recipes

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

The recipe directory #

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

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

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

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

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

pekit.toml #

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

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

The one pekit itself ships with is deliberately minimal:

out_dir = "out"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

[clean]
command = "cargo clean"

The top-level keys break down as follows:

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

Targets are shell commands #

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

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

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

[build]
command = "make"

…or in named form, one subtable per target:

[build.main]
command = "make"

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

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

The PEKIT_* environment contract #

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

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

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

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

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

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

Shell variables, not templates #

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

How pekit finds the recipe #

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

You can override this:

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

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

Where to go next #

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

Sources

pekit / Writing recipes

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

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

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

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

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

The four source kinds #

[source.git] #

Clones a git repository and checks out a ref. It takes five fields — a required url (passed to git clone --mirror), a ref to check out (templated with the selected version; defaults to {{version}} and must render non-empty), a versions cap that filters enumerated or requested versions, and a tag_regex used when enumerating tags (see Enumeration). The fifth, tracked_path, selects the moving-ref snapshot mode described below. An optional [source.git.signature] table verifies the upstream's release signatures; see Signed Git releases. The field-by-field schema is in the recipe format reference.

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

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

Note

There is no submodule option. A submodules key is an unknown_key error.

Signed Git releases #

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

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

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

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

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

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

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

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

Tracking one file on a moving ref #

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

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

This is deliberately a bounded mode:

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

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

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

[source.url] #

Downloads a file over HTTP(S), optionally extracting it as an archive. The required url is templated with the selected version; extract (default false) treats the download as an archive, with root naming the subdirectory inside the archive to promote as the source tree; versions is a version cap (as for git); listing_url can name a release page when the download template does not live in a browsable directory; file_regex is used when enumerating that listing. Like Git tag_regex, named version or component captures can map upstream filenames into the catalogue's version form; the URL and archive root then render from that mapped version. checksum pins the download to an expected digest — a single string or a per-version table (see below). The full schema is in the recipe format reference.

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

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

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

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

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

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

Signed checksum manifests #

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

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

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

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

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

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

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

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

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

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

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

[source.pypi] #

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

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

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

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

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

[source.local] #

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

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

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

Additional inputs #

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

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

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

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

Three things differ from a source:

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

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

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

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

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

Inputs in a delegated source #

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

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

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

Selecting the local override #

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

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

Both accept an inline path:

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

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

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

Materialisation #

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

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

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

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

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

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

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

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

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

The downloaded artifact is cached regardless. The materialised tree is reused, including for checksum-pinned sources, while its manifest's source identity matches: source kind, rendered URL, checksum, extraction settings, root, provenance and patches. Checksum and lock verification still run on every resolve. An identity change invalidates the materialised scope and its staged outputs.

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

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

Caching and --refresh-source #

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

  • git: removes the mirror repo and the checkout scope, forcing a fresh clone --mirror and checkout. Tracked-path git removes its sparse cache and refetches the selected immutable commit (for an exact lock) or moving ref (during discovery), without changing what the lock asserts.
  • url / PyPI: removes the cached artifact and the materialised scope, forcing a re-download and re-extract.
  • local / sourceless: nothing to refresh.

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

The lockfile #

Fetched inputs are pinned trust-on-first-use in a machine-written pekit.lock beside pekit.toml. The first time a source resolves for a version, pekit records what it fetched — the artifact's SHA-256 for a url or PyPI source, every ordered remote-patch URL and SHA-256 when present, or the resolved commit for an ordinary git source, with the verified signer's fingerprint when the recipe has a [source.git.signature]. A tracked-path git entry records the repository, fixed ref, path, commit, blob object ID, and blob SHA-256. Every later resolve verifies against that entry instead, cache hits included. A mismatch is a hard lock_mismatch stop: upstream's published bytes (or a tag) changed under a version that was already pinned. Accepting such a change is an explicit ceremony, never automatic for ordinary git and URL sources:

pekit lock --repin --version 1.2.0

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

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

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

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

Source packages #

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

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

Archive extraction #

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

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

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

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

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

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

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

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

Patches #

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

[source]
patches = "patches"

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

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

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

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

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

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

Enumeration #

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

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

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

Source roots and provenance #

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

KindProvenance ref
sourcelessrecipe:<recipe-root>
locallocal:<path>
gitgit:<url>@<commit>
tracked-path gitgit:<url>@<commit>:<path>#sha256:<blob-sha256>
url (checksummed)url:<url>#<checksum>
url (locked, no checksum)url:<url>#sha256:<hash>
url with remote patchesthe locked url ref above plus +patches:sha256:<series-hash>
url (no checksum, no lock entry)url:<url> (marked unanchored — reachable only in a dry run, since a real resolve locks)
PyPIpypi:<normalized-project>@<version>#sha256:<hash>

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

Delegation #

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

delegate = true

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

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

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

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

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

Where to go next #

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

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

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

Versions

pekit / Writing recipes

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

Version forms #

A version is written as:

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

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

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

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

Version template variables #

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

VariableExpands to
{{version}}the version exactly as written
{{major}}the first numeric component
{{minor}}the second numeric component
{{patch}}the third numeric component
{{revision}}the fourth numeric component, or empty
{{revision_suffix}}-rev plus the fourth component, or empty
{{suffix}}the unseparated suffix, or empty
{{prerelease}}the prerelease tail, or empty
{{buildmeta}}the build-metadata tail, or empty
[source.git]
url = "https://github.com/example/tool.git"
ref = "v{{version}}"

Three rules govern rendering:

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

  • Referencing a component the version does not have is an error. {{version}} fails when no version is selected at all; {{minor}} and {{patch}} fail when the selected version stops short of that component (for example {{patch}} against 2.43). {{suffix}}, {{prerelease}}, and {{buildmeta}} are the exception — they render as empty text when absent rather than erroring. {{revision}} and {{revision_suffix}} behave the same way.

  • Unknown variables are an error. Any {{name}} that is not one of the nine above (or the multi-package {{multipack}} token) is rejected. There is no silent pass-through.

Important

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

Selecting versions #

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

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

Enumerable sources are those pekit can list versions for: an ordinary [source.git] source (via git ls-remote --tags), a tracked-path git source (from matching lock history plus a changed blob at its fixed ref), a [source.url] source (by fetching the listing directory), or a [source.pypi] source (from the project's JSON Simple API page). --latest, --all-versions, and any constraint require one; against a non-enumerable source they fail with selected source cannot enumerate versions.

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

Selecting from the lock #

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

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

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

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

--version as an exact selector #

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

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

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

--version as a constraint #

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

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

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

--latest and --all-versions #

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

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

The trailing-zero ladder #

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

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

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

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

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

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

Source version caps #

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

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

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

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

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

Local builds #

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

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

Single-version commands #

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

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

Where to go next #

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

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

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

Packaging files

pekit / Writing recipes

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

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

The [files] table #

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

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

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

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

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

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

Source refs #

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

Build-output refs — target:path #

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

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

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

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

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

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

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

Plain paths are owner-relative #

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

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

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

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

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

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

Note

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

Path cleaning #

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

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

Globs #

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

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

Rules:

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

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

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

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

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

Excludes #

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

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

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

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

Destination rules #

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

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

Directory vs single-file destinations #

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

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

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

Collisions are errors #

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

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

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

override #

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

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

[symlinks] #

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

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

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

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

The result #

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

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

Where to go next #

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

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

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

Dependencies and claims

pekit / Writing recipes

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

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

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

Declared package dependencies #

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

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

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

Each entry maps a name to a version constraint string. A concrete package is named by its canonical reverse-DNS identity; unqualified package aliases are not a supported compatibility layer. A virtual capability instead names an interchangeable interface, such as a soname (libc.so.6), a pkgconfig(...) token, or an explicitly documented role such as sh. The constraint is any peipkg constraint expression (">= 1.2", "< 2", and so on). Both "*" and the long form below with the constraint omitted mean any version.

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

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

[dependencies]
# short form: constraint only, depender's own root
"io.github.zlib-ng.zlib-ng-compat" = ">= 1.2"

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

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

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

Derived dependencies: ELF and pkgconfig scanning #

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

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

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

What ELF derivation detects #

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

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

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

Two refinements keep the result accurate:

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

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

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

Retaining an older library ABI #

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

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

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

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

Foreign static Linux targets #

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

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

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

Symbol version policy #

By default a soname dependency is derived at soname granularity only: the name must match exactly, with no version constraint. That alone cannot establish symbol-version compatibility: it cannot express "needs at least the glibc that introduced GLIBC_2.34".

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

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

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

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

A soname absent from the policy is derived at soname granularity only, as above. The policy is a distro-wide assertion and lives only in the workspace file. It accepts symbol_versions and symbol_capabilities; unknown keys are errors. Recipes and package files cannot set it. When no workspace policy is configured, every soname is derived at name granularity.

Exact ELF version capabilities #

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

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

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

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

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

Build dependencies exported to the build command #

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

[build.main]
command = "make"

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

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

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

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

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

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

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

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

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

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

Debian reference dependencies without a Debian package #

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

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

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

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

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

Claims #

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

Important

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

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

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

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

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

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

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

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

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

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

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

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

Where to go next #

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

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

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

Multi-package recipes

pekit / Writing recipes

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

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

Package files and layers #

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

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

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

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

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

How many packages a recipe emits #

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

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

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

Merge and precedence #

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

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

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

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

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

Selecting packages on the CLI #

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

Selection works like target selection:

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

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

invalid_flags: --all cannot be combined with package selectors

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

Multipack: one definition, many instances #

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

Static enumeration #

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

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

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

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

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

Enumeration from files #

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

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

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

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

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

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

The {{multipack}} variable #

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

{{multipack}} is not available in this context

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

Selecting one instance #

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

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

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

Instance naming and artifacts #

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

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

Dry runs and unresolved instances #

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

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

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

Worked example: runtime plus docs #

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

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

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

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

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

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

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

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

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

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

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

Where to go next #

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

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

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

Environments and keyrings

pekit / Writing recipes

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

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

The three layers #

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

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

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

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

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

The [env] block #

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

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

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

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

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

Composition and override #

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

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

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

Shell expansion of [env] values #

For the common case — a shell target, or any target run under a [wrap] wrapper — pekit emits the user variables as export lines in a shell script that runs before your command, using double quotes that preserve spaces. This means [env] values are expanded by the shell and may reference other variables:

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

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

Env files and --env #

An env file is a supporting file at the workspace root or beside a recipe. It carries an [env] block, a [wrap] wrapper, a dependency_provider, a [sandbox] root preparer, a [lint] table, or any combination of them. It lets you keep environment- or profile-specific settings out of the recipe proper. An env file must declare at least one of env, wrap, dependency_provider and sandbox (see Isolated production jobs). The only other recognised top-level key is lint, which adds rule exceptions that belong to the environment rather than to any package.

Which env file is loaded is controlled by --env <name>. The default is main, so env.pekit.toml is picked up automatically when present and silently skipped when absent. Any other name selects a profile such as release.env.pekit.toml (--env release). In a workspace, pekit loads that file from the workspace root and then from the recipe directory; either may be absent, but a named profile must exist in at least one location. A recipe-local file overlays the workspace profile. Outside a workspace, the recipe-local file retains the same semantics. If both paths resolve to the same underlying file (for example through a legacy member symlink), pekit applies it only once. Passing --env none disables both layers.

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

[env]
CFLAGS = "-O3 -DNDEBUG"

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

[wrap] wrappers #

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

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

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

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

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

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

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

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

dependency_provider #

Env files may also set dependency_provider, a single string naming which of a target's declared [build.<target>.dependencies.<provider>] blocks is exported to the build environment (PEKIT_DEPENDENCIES, PEKIT_DEPENDENCY_PROVIDER, PEKIT_DEPENDENCIES_FILE). It is not an environment variable; it selects a declared provider block by name. The precedence is the same as for wrappers: selected workspace profile, delegated source env file, then selected recipe env file. The last non-empty value wins. See Dependencies and claims for what the selection exports.

Keyrings #

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

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

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

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

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

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

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

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

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

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

pekit build --keyring.tcb.priv=<value>
# coordinator-only; never exported to the target

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

Precedence #

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

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

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

Package signing #

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

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

Binary signing #

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

Keyring file format #

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

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

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

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

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

A note on secrets in output #

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

Isolated production jobs #

A workspace can require Pekit-controlled isolation:

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

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

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

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

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

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

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

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

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

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

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

Where to go next #

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

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

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

Commands and targets

pekit / Running pekit

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

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

The commands #

CommandSelectsVersions--allSide effects
buildbuild targets + their needsmultiplenoRuns build targets; stages their output under out_dir.
testtest targets + the builds they needsingle resolvednoStages needed builds, then runs the selected test targets.
installinstall targets + the builds they needsingle resolvednoStages needed builds, then runs the selected install targets.
packagepackage members + gatesmultipleyesStages builds, runs gated tests, writes .peipkg artifacts under out_dir, then lints them. Recipes with a reproducible source also emit a corresponding-source package.
publishpackage members (as package)multipleyesPackages as above, then publishes to configured localdir and/or Peipkg repository destinations.
cleanone optional clean targetnonenoRuns a clean target and/or removes the managed output directory.
gengen targetsnoneyesRuns gen commands; writes generated source into the tree.
verifygen targetsnoneyesRuns gen verify_commands; a read-only drift check (writes nothing).
lockthe recipe's sourcemultiplenoFetches and pins the selected versions in pekit.lock without building. With no version flags, reports the current lock instead.
lintthe recipe and every package it definesnone, or multiplenoChecks 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.
workspacea delegated command across members(delegated)(delegated)Runs one of the above across every workspace member.
helpa command name, optionallynonenoPrints the overview, or one command's flags and selectors. --help / -h anywhere do the same.
versionnothingnonenoPrints 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:

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

[test]
command = "make check"

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

Named targets #

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

[build.lib]
command = "make lib"

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

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

Bare and named cannot mix #

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

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

Target selection #

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

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

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

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

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

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

Build dependencies (needs) #

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

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

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

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

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

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

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

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:

[test.release]
needs = ["main"]
gate = true
command = "./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.lock files, which lock --latest maintains unattended and which verify themselves against upstream;
  • every resolved source is pinned in pekit.lock; a --local source or an unlocked one is refused;
  • no bypass flag is present: --no-gates, --no-build, --no-verify, --local, --prefer-local, --allow-unanchored and --allow-unsigned are 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-build still 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:

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

Details:

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

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

invalid_selector: clean accepts at most one target selector

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

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.
  • --all selects every member.

--all is exclusive with explicit selectors:

invalid_flags: --all cannot be combined with package selectors

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

Generating source (gen and verify) #

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

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

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

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

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

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

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

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

Pre-flight drift checking #

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

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

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

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

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

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

--no-verify and verify_dependencies #

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

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

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

Selector rules #

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

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

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

See also #

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

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 =value on a no-value flag;
  • the mutual-exclusion rules above;
  • bad selectors (a selector beginning with -, or more selectors than the command accepts).

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

--dry-run #

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

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

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

Output renderers #

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

Human (default) #

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

--quiet #

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

--verbose #

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

--json #

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

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

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

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

Exit codes #

Pekit uses two exit codes only: 0 on success, 1 on any error — bad invocation, missing recipe, failed build, and so on. There are no finer-grained codes. 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:

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

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

Remote recipe locators #

A remote locator is a recipe reference that points at a git repository rather than a local path. 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:

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

The workspace.pekit.toml file #

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

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

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

[env]
LC_ALL = "C"

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

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

Member discovery #

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

Discovery proceeds as:

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

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

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

Note

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

Locating the workspace #

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

Member identity #

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

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

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

Invocation #

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

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

Strict flag placement #

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

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

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

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

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

Note

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

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; --jobs runs 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
tags = ["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-fast is set).

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

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

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

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

Cross-member publish collision detection #

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

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

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

Shared configuration #

[env] and [wrap] #

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

Policy: symbol-version 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:

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

[policy.symbol_versions]
"libc.so.6" = "GLIBC_"
"libgcc_s.so.1" = "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_key error. Pekit never falls back to silently writing unsigned output — a signing setup that degrades quietly would defeat the point.
  • Without a key, package writes unsigned artifacts (fine for local development), but publish refuses peipkg-format packages with unsigned_publish unless you pass --allow-unsigned.

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:

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

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

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

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

For each matched file, pekit:

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

Two properties are deliberate:

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

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

What the manifest records #

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

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

Together these state the full provenance chain — the exact upstream inputs, the exact recipe that drove the build, and the exact tool that performed it. recipe_ref deliberately resolves the enclosing repository: a workspace member's identity is the workspace repository's commit, not some per-recipe notion.

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 dirtyDoes not
The selected recipe directory — pekit.toml, its package definitions, pekit.lock, source patches, embedded upstream keysOther 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, keyringsPekit'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 notThe 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.enabled is not set to false (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 committed pekit.lock; for an ordinary git source, a git archive export 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 under upstream/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/ and patches/ — compatibility views for older source consumers. New reconstruction tooling uses the complete workspace/ topology.
  • acquisition/vendor/ — captured outputs of an isolated build.vendor stage, 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.py and REBUILD.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:

export PEKIT_DEBIAN_ROOT_STORE=/srv/releases/debian-roots
export PEKIT_DEBIAN_REPLAY="$PWD/build-environment"
python3 rebuild.py test

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

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 #

[build.kernel.sign.module]
"modules-root/**/*.ko.zst" = "modsig.priv"
"modules-irf-root/**/*.ko.zst" = "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.

[package]
name.style    = "reverse-dns"
license       = "spdx"
license_class = true
description   = 80

[source]
discovery      = true
versions.floor = true
lock           = true

[elf]
pie   = true
relro = "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:

  1. the delegated source root, when there is one;
  2. the workspace root;
  3. each directory down to the recipe directory;
  4. 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:

[allow_files."payload.junk"]
"usr/lib/compiler/*/crtbegin.o" = "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:

[allow]
"source.reproducible" = "non-public: grants unauthenticated SYSTEM access to a DWE guest"
"payload.manpages.required" = "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.

RuleValueChecks
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.licensetrue 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_classtrueA peipkg-format package declares license_class. Other formats are not checked.
package.homepagetrue or "https"A homepage is declared; with "https", it is an https URL.
package.descriptiontrue or a lengthA 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" payloadA 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:

[package]
references = "reverse-dns"
virtual_capabilities = ["init", "sh"]

[source] #

Checked against the recipe's [source] table. Rules for a source kind the recipe does not use do nothing.

RuleValueChecks
source.reproducibletrueThe source is not [source.local] alone. A recipe with no source at all passes.
source.discoverytrueA 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.floortrueA 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.locktruepekit.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.requiredtrueA 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.keystrueEvery key_files entry exists and is not empty.
source.patches.headerstrueEvery 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.statustrueEvery 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] #

RuleValueChecks
build.dependencies.providersarray of provider namesEvery 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.testtrueThe 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.

RuleValueChecks
payload.junktrue or an array of patternsNo 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.scriptstrueA 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.requiredtrueEvery 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.pkgconfigtrueNo .pc file contains the build directory path.
payload.license_filea path templateEvery 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] #

RuleValueChecks
split.devel.packagesa name pattern, or an arraypayload. 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.

RuleValueChecks
elf.pietrueNo 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.relrtrueA dynamic object with relative relocations left in .rela.dyn has none, or carries DT_RELR; the linker flag is -z pack-relative-relocs.
elf.cettrueAn 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.strippedtrueNo .debug_* sections and no .symtab.
elf.debuginfoa package-name templateThe 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.sonametrueA 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 an unknown_key diagnostic. There is no lenient/forward-compatible mode.
  • Types are checked on decode. A key declared here as a string must be a string, a table must be a table, and so on; a mismatch is an invalid_type error.

pekit.toml #

Worked example #

out_dir = "dist"

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

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

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

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

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

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

[test]
command = "make check"

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

[clean]
command = "make clean"

Top-level keys #

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

KeyTypeRequiredMeaning
out_dirstringnoManaged 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".
envtablenoEnvironment variables exported to every target command. See [env].
wraptablenoCommand wrapper applied to every target. See [wrap].
sourcetablenoWhere the recipe's source tree comes from. See [source].
inputtablenoAdditional authenticated upstream inputs, keyed by name. See [input.<name>].
delegatebool or tablenoBorrow build/env/wrap/package definitions from the source tree. See [delegate].
source_packagetablenoCorresponding-source package emission control. See [source_package].
tagsarray of stringsnoLabels 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.
buildtablenoBuild target(s). See [build] / [test] / [install] / [clean].
testtablenoTest target(s).
installtablenoInstall target(s).
cleantablenoClean target(s).
gentablenoSource-generating target(s). See [gen].

[env] #

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

KeyTypeRequiredMeaning
<NAME>string—Value for variable NAME.

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

[wrap] #

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

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

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

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

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

[source] #

Selects and configures the source tree. It has 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-tableMeaning
[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:

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

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

[source.git] #

KeyTypeRequiredMeaning
urlstringyesGit remote URL to clone.
refstringnoRef (tag/branch/commit) to check out. Templated. Default "{{version}}".
versionsstringnoVersion cap: a constraint string filtering enumerated or requested versions (see Versions).
tag_regexstringnoRegex 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_pathstringnoTrack 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.
signaturetablenoUpstream 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.

KeyTypeRequiredMeaning
objectstringnoWhat 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_filesstring arrayyesPinned public keys (armored or binary, one or more per file), relative to the recipe directory. An empty or absent list is missing_key.
fingerprintsstring arraynoAllowlist of signer primary-key fingerprints, as for [source.url.signature]. A valid signature by any other pinned key is signature_untrusted_key.
ignore_expiryboolnoPermit a signature made after the pinned signing key expired, as for [source.url.signature]. Default false.

[source.url] #

KeyTypeRequiredMeaning
urlstringyesURL to download. Templated with the version.
listing_urlstringnoExplicit URL to fetch when enumerating versions. Use when releases are listed separately from their download paths; default is derived from url.
extractboolnoTreat the download as an archive and extract it. Default false.
rootstringnoSub-directory within the extracted tree to use as the source root. Default ".".
excludestring arraynoExact 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.
versionsstringnoVersion cap: a constraint string filtering enumerated or requested versions (see Versions).
file_regexstringnoRegex 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.
checksumstring or tablenoExpected checksum. A bare string applies to all versions; a table maps version → checksum.
signaturetablenoUpstream signature verification — see [source.url.signature] below.
patch_seriestablenoIncremental 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] #

KeyTypeRequiredMeaning
projectstringyesPyPI project name. Must start and end with an ASCII letter or digit and otherwise contain only letters, digits, ., _, or -.
artifactstringyesDistribution kind. Currently must be exactly "sdist".
versionsstringnoVersion 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.

KeyTypeRequiredMeaning
key_filesstring arrayyesCommitted public key files (armoured or binary OpenPGP), resolved relative to the recipe root. Must be non-empty.
urlstringno (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".
ofstringnoWhat 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.
fingerprintsstring arrayno (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_expiryboolnoPermit 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.

KeyTypeRequiredMeaning
urlstringyesPatch artifact URL template. Must contain {{patch}}; {{major}}, {{minor}}, and other version variables are available.
file_regexstringnoRegex used against the patch-directory listing. Must contain a named patch capture. When omitted, Pekit derives the matcher from url.
patch_widthintegernoZero-pad {{patch}} to this width. Default 0 (no padding).
stripintegernoNon-negative path-component count passed to patch -p. Default 0.
signaturetablenoPer-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] #

KeyTypeRequiredMeaning
pathstringyesPath 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:

DifferenceMeaning
versionsMust 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_seriesNot 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.

KeyTypeRequiredMeaning
allboolnoDelegate everything (build, env, wrap, packages).
buildboolnoDelegate build targets, together with the [input.<name>] tables the source declares for them.
envboolnoDelegate [env].
wrapboolnoDelegate [wrap].
packagesboolnoDelegate package definitions.

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

[source_package] #

Controls the corresponding-source package a recipe emits alongside its binary packages. Emission is automatic; the table 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.

KeyTypeRequiredMeaning
namestringnoPackage name. Default <recipe-dir>-source.
enabledboolnoSet false to emit no source package. Default true.
workspace_inputsarray of relative pathsnoAdditional 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.
licensestringnoSPDX 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 committed pekit.lock, or a git archive export of the locked commit.
  • source/ — the prepared tree, with patches already applied. Large trees travel in prepared-source.tar instead; rebuild.py verifies and reconstructs source/ before invoking Pekit.
  • workspace/ — the complete captured recipe, shared inputs, inherited policy and environment profiles, preserving their relative paths.
  • recipe/ and patches/ — compatibility views.
  • acquisition/ and build-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] with command = "make". This defines a single target named main.
  • Named targets: the section contains sub-tables, e.g. [build.docs] and [build.linked], each a target. Each name must be a valid selector.

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

Fields of a single target:

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

Any other key in a target table is an unknown target key error. 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:

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

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

[build.<name>.sign] #

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

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

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

LevelTypeMeaning
kindtableA signature kind. 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>stringA relative path or glob (the same glob syntax as [files]). The value is a keyring leaf path such as tcb.priv whose value is the path to the private key. Must be non-empty.

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

[gen] #

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

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

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

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

package.pekit.toml #

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

Worked example #

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

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

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

[optional_dependencies]
libcurl = ">=8.0"

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

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

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

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

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

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

Top-level keys #

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

KeyTypeRequiredMeaning
formatstringnoArtifact format: "tar" or "peipkg". Default "tar".
dependency_providerstringnoThe 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_outboolnoWipe the package stage directory before building. Default true.
buildsarray of stringsnoNames of build targets this package requires before staging.
packagetablenoPackage metadata. See [package].
filestablenoPayload file mappings. See [files].
symlinkstablenoSymlinks to embed in the payload. See [symlinks].
excludesarray of stringsnoRefs/globs to drop from directory payloads. See excludes.
multipacktablenoExpand into a family of instances. See [multipack].
publishtablenoWhere to copy finished artifacts. See [publish].
dependenciestablenoRuntime dependencies. See [dependencies].
optional_dependenciestablenoOptional runtime dependencies (same schema as dependencies).
conflictstablenoname → constraint of packages this one conflicts with.
providestablenoname → version of capabilities this package provides.
replacestablenoname → constraint of packages this one replaces. "*" means every version, as it does in dependencies; the manifest records such an entry with no constraint.
side_effectsarray of stringsnoDeclared install-time side effects.
sd_overridestablenopayload path → SDDL security descriptor. See [sd_overrides].
claimstablenoClaim slots on provides/dependencies. See [claims].

Important

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

Most string-valued fields (metadata, dependency names/constraints, file refs and destinations, claim paths/targets) are run through the template engine, where {{version}} and {{multipack}} are available. 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.

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

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

[dependencies] #

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

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

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

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

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

[files] #

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

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

Table form keys — only these are accepted:

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

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

[symlinks] #

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

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

Table form keys — only these are accepted:

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

excludes #

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

[multipack] #

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

Explicit list:

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

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

Enumerated from files:

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

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

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

See Multi-package recipes.

[publish] #

Where finished artifacts are published. localdir copies an artifact by basename and is written as an array of tables:

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

Each entry accepts only path and overwrite:

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

peipkg publishes peipkg-format artifacts into a complete signed repository and is written as one table:

[publish.peipkg]
path = "dist/repository"
name = "experimental"
signing_key = "keyring:signing.repository_key"
KeyTypeRequiredMeaning
pathstringyesRepository state directory, relative to the workspace root (or recipe root outside a workspace).
namestringnoName written into a newly initialized repository's signed descriptor. Defaults to the final component of path; it does not alter an existing repository.
signing_keystringyesRepository 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.

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

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

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

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

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

See also #

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

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

[sd_overrides]
"home" = "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, WD and literal SIDs are fine while domain- and machine-relative aliases (DA, EA, LA and the rest) are an error.
  • A rights field may end in a hex literal. FRFX, 0x1200af and FRFX0x6 all 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_FILE and FILE_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.

Note

A declared descriptor only takes effect if the consumer is willing to apply it. peipkg install gates that on the originating repository's allow_sd_overrides setting and refuses a package that carries overrides it may not apply, rather than installing it with them dropped. An image composed by peipkg-compose applies them unconditionally.

Supporting files and reference tables

pekit / Reference

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

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

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


workspace.pekit.toml #

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

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

KeyTypeRequiredMeaning
symbol_capabilitiesarray of stringsnoSONAMEs whose exact GNU ELF version nodes become elfver(soname:token) dependencies and provides, independently of package versions.
[policy.symbol_versions]table (string → string)noMaps a shared-library soname to the symbol-version token prefix whose tokens are commensurable with the providing package's version. Governs which sonames get a symbol-version floor during dependency derivation.

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

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

[policy.symbol_versions]
"libc.so.6"     = "GLIBC_"
"libgcc_s.so.1" = "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).

KeyTypeRequiredMeaning
[env]tableone of the fourEnvironment variables. See env table.
[wrap]tableone of the fourCommand wrapper. See wrap table.
dependency_providerstringone of the fourSelector 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)noWhole 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:

[lint.allow]
"elf.cet" = "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 valueFile loaded at each rootMust exist?
(unset) or mainenv.pekit.tomlno (silently absent at either root)
none(no env file loaded)n/a
any other <name><name>.env.pekit.tomlyes — 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.

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

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

[wrap] table #

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

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

{{command}} rules:

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

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


*.keyring.pekit.toml #

A keyring file supplies 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 pathExported variable
tokenPEKIT_KEYRING_TOKEN
github.tokenPEKIT_KEYRING_GITHUB_TOKEN
registry.api-keyPEKIT_KEYRING_REGISTRY_API_KEY
# placeholders only — never commit real secrets
token = "REPLACE_ME"

[github]
token = "REPLACE_ME"

[registry]
api-key = "REPLACE_ME"

Restrictions #

RuleDetail
Leaf typeA string (coordinator-only), or a typed table with string value and access.
NestingSub-tables nest arbitrarily; each nesting level adds a segment to the exported name.
Typed accesspublic permits requesting targets; acquisition permits only build.vendor; signing is coordinator-only. path and content forms remain unsupported.
CollisionsA 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 pathMeaning
signing.package_keyPath to an Ed25519 private key — a raw 32-byte seed, or PKCS#8 PEM as produced by openssl genpkey -algorithm ed25519. When present, every peipkg-format artifact the run produces — package members and -source packages alike — is packed with an embedded signature naming the key's fingerprint, and each emits a sign event. A relative path resolves against the invocation's working directory. A configured key that cannot be loaded is an error (signing_key); pekit never silently falls back to unsigned output.

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

A build target's sign table reads further entries, but which ones is the recipe's choice: each sign.pip value is the dotted path of a leaf holding the path to an ML-DSA-65 private key. Those 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.

KeyTypeRequiredMeaning
[package], [source], [build], [payload], [split], [elf]tablesnoRule 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)noExemptions. 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 #

KeyTypeMeaning
schemaintegerLockfile schema version. Currently 1.
[[source]]array of tablesOne entry per locked version, kept sorted by version.
[[input]]array of tablesOne 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:

KeySet forMeaning
versionallThe locked version. Empty for a versionless url fetch.
urlurl and PyPI sourcesThe rendered or selected URL fetched at lock time. Provenance only — the hash, not the address, is the assertion.
sha256url and PyPI sourcesSHA-256 of the fetched artifact.
refgit sourcesThe rendered ref the version resolved through.
commitgit sourcesThe commit the ref resolved to — the assertion.
repositorytracked-path git sourcesGit repository URL bound by the snapshot.
pathtracked-path git sourcesFixed repository-relative regular file selected by the recipe.
blobtracked-path git sourcesGit blob object ID reached by commit:path.
blob_sha256tracked-path git sourcesSHA-256 of the exact blob bytes; the transport-independent content assertion.
signature_keyurl 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_atallUTC 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:

KeyMeaning
nameThe input's name, matching its [input.<name>] table.
versionThe 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.
urlThe rendered URL fetched at lock time. Provenance only.
sha256SHA-256 of the fetched artifact — the assertion.
signature_keyHex fingerprint of the pinned upstream key that verified it, when a signature block is configured.
locked_atUTC timestamp of the pinning run (RFC 3339).

Worked example #

schema = 1

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

  [[source.patch]]
    url = "https://example.org/dash-0.5-patches/dash05-001"
    sha256 = "9b44d9f7a36d1a20537817f92da72bf10eb81f66df860f23298b2d4e830a6d8d"
    signature_key = "9f0b7ddc1325a3e2c40e5f0a88b1c62f3d94ab07"

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

[[source]]
  version = "2026.09.06.2"
  repository = "https://github.com/example/upstream.git"
  ref = "refs/heads/release"
  path = "security/trust/certdata.txt"
  commit = "6b839fbb82801a7a5f5e37df0864c149fddda2b7"
  blob = "65fddc0b380650ee16aa47e4e28e3a5e2c95f201"
  blob_sha256 = "fa5092f93c2d5352ebf6c9f4f26f4232a2e2bc0fd8a8950756c1984984b490e0"
  locked_at = "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:

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

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

Worked example #

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

with a series of:

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

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


Template variables #

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

VariableSourceAvailability
{{version}}the selected version, verbatim (Raw)Errors when no version is selected.
{{major}}major componentAvailable once a version is selected.
{{minor}}minor componentErrors if the selected version has no minor component.
{{patch}}patch componentErrors if the selected version has no patch component.
{{suffix}}unseparated upstream suffixEmpty 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 valueOnly 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 variableEnvironment 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 formRootMeaning
target:paththe build target's output stageReads from the output of build target target.
:pathbuild target main's output stageSame as above with the target name defaulted to main.
@source:paththe materialised source tree (LiteralRoot)Reads from the checked-out / extracted source.
@recipe:paththe recipe directoryReads a file shipped next to the recipe.
@workspace:paththe workspace rootReads a file from the workspace. Errors if used outside a workspace.
bare pathowner defaultA path with no : normalises to the owning layer's root: @recipe: for recipe packages, @source: for source packages, @workspace: for workspace packages.

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


Publish targets #

The publish command 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:

[[publish.localdir]]
path = "dist/{{version}}"
overwrite = false
KeyTypeRequiredMeaning
pathstringyesDestination 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.
overwriteboolno (default true)Whether an existing destination file may be replaced. When false, an existing destination is an error (publish_exists).

Resolution details:

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

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:

[publish.peipkg]
path = "dist/repository"
name = "experimental"
signing_key = "keyring:signing.repository_key"
KeyTypeRequiredMeaning
pathstringyesRepository state directory, relative to the base root. It is not package-instance templated.
namestringnoStable repository name used only when initializing path. Defaults to the final path component. An existing repository's signed descriptor is authoritative.
signing_keystringyesEd25519 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.toml and package-file schemas.
  • Environments and keyrings — how env and keyring files are selected and layered.
  • Signing and provenance — signing.package_key and pekit.lock in the trust chain.

Command-line reference

pekit / Reference

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

Synopsis #

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

<command> is exactly one of build, test, install, package, publish, clean, gen, verify, lock, 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 #

InvocationOutput
pekit help, pekit --help, pekit -hThe overview: every command with a line each, the global flags, and a pointer to this page. Exit 0.
pekit help <command>, pekit <command> --helpThat 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 versionpekit <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.

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

Global flags #

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

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

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

Version, source, and build flags #

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

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

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

Workspace flags #

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

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

FlagValue formMeaning
--tag <tag>Required-value, repeatableRun only members whose recipe tags include any given tag.
--exclude-tag <tag>Required-value, repeatableSkip 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.

Commandversion selectionlocal source--no-build--no-verify--no-gates--env--keyring--refresh-source--allow-unanchored--allow-unsigned--replace--allclean mode--repin--strict
buildYesYesYesYesYesYesYesYes
testYesYesYesYesYesYesYesYes
installYesYesYesYesYesYesYes
packageYesYesYesYesYesYesYesYesYesYes
publishYesYesYesYesYesYesYesYesYesYesYesYesYes
cleanYesYesYes
genYesYesYes
verifyYesYesYes
lockYesYesYes
lintYesYesYesYes
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 conditionDiagnostic
--quiet with --verbose--quiet and --verbose cannot be used together
--quiet with --json--quiet and --json cannot be used together
--recipe with --workspace--recipe and --workspace cannot be used together
--workspace without the workspace command--workspace can only be used with the workspace command
No command wordmissing_command
More than one of --version / --latest / --all-versionsmutually exclusive
--locked without --latest, --all-versions or a version constraint, or on lockinvalid_flags
--local with --prefer-localmutually exclusive
--output-only with --target-onlymutually exclusive
A recognised flag the effective command does not supportunsupported_flag (suppressed under --allow-unused)
clean --output-only with --env or --keyring"clean --output-only does not run a target, so … is unused" (suppressed under --allow-unused)
clean with more than one selectorinvalid_selector (clean accepts at most one)
package/publish with --all and a selector"--all cannot be combined with package selectors"
gen/verify with --all and a selector"--all cannot be combined with gen/verify target selectors"
lock with any selectorinvalid_selector (lock does not accept selectors)
lock --repin without an exact single --versioninvalid_flags (--repin requires exactly one exact --version)
lint with any selectorinvalid_selector (lint checks every package)
lint --allunsupported_flag
help <name> where <name> is not a commandunknown_command
version with any selectorinvalid_selector
workspace help or workspace versionmissing_workspace_command
--no-verify naming an unknown gen targetmissing_target
A positional selector beginning with -invalid_selector (use -- before selector-like values)

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

Exit codes #

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

There are no other exit codes.

Environment contract #

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

Always set #

Exported for every target pekit runs.

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

Set only in an isolated workspace #

When the workspace enables isolation, the profile's root preparer and the sandboxed target see a few more variables.

VariableSeen byValue
PEKIT_SANDBOX_ROOTRoot preparerThe fresh directory the preparer populates as the target's root.
PEKIT_JOB_STATERoot preparerCoordinator-only storage for the job, never mounted into the sandbox.
PEKIT_NATIVE_ROOTTarget1 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.

VariableValue
PEKIT_VERSIONFull version string, including fourth and later numeric components.
PEKIT_VERSION_MAJORFirst numeric component.
PEKIT_VERSION_MINORSecond numeric component.
PEKIT_VERSION_PATCHThird numeric component.
PEKIT_VERSION_SUFFIXUnseparated upstream suffix (may be empty).
PEKIT_VERSION_PRERELEASEPre-release component (may be empty).
PEKIT_VERSION_BUILDMETABuild-metadata component (may be empty).

Per-dependency and per-keyring variables #

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

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

See also #

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

Peios Learn — documentation for the Peios project.

Built with Trail.