Writing recipes
Single-page view · as markdown
Anatomy of a recipe
pekit / Writing recipes
A recipe is not a single file — it is a directory. One file, pekit.toml, is the recipe proper; the others beside it describe what to package, which environment to build in, and which secrets to sign with. This page walks through that file set, then focuses on pekit.toml itself and the one interface every recipe author leans on: the PEKIT_* environment variables that pekit hands to each target command.
The recipe directory #
Everything pekit needs to build one piece of software lives in a single directory. A rich recipe looks like this:
mypkg/
├── pekit.toml # the recipe: source, targets, env, wrap, delegate
├── package.pekit.toml # base package: files, symlinks, dependencies, metadata
├── packages.pekit/ # (optional) extra package members, one file each
│ ├── docs.package.pekit.toml
│ └── dev.package.pekit.toml
├── env.pekit.toml # (optional) reusable environment / wrapper
├── prod.keyring.pekit.toml # (optional) named keyring for signing and secrets
└── ...
workspace.pekit.toml # (optional) one level up — groups recipes into a workspace
Only pekit.toml is required. The rest are opt-in and each has its own page:
| File | Purpose | Page |
|---|---|---|
pekit.toml | The recipe: where source comes from, how to build/test/install/clean it, shared env. | this page |
package.pekit.toml | The base package: how staged output maps into a .peipkg, plus dependencies and metadata. | Package files |
*.package.pekit.toml, packages.pekit/ | Additional package members for a recipe that produces more than one package. | Multi-package recipes |
env.pekit.toml, *.env.pekit.toml | Reusable environment variables and a shell wrapper to run commands under. | Environments and keyrings |
*.keyring.pekit.toml | Named collections of secrets exported into the build as PEKIT_KEYRING_*. | Environments and keyrings |
workspace.pekit.toml | Sits one level up and groups a directory of recipes into a workspace pekit can drive as a unit. | Workspaces |
pekit discovers the package, env, and keyring files by their fixed names in the recipe directory (packages.pekit/ is searched as an alternative location for package files). The base package file may live at package.pekit.toml or packages.pekit/package.pekit.toml, but not both. The supporting-files reference lists every name and location: Supporting files.
pekit.toml #
The recipe file is a TOML document with a small, fixed set of top-level keys. pekit is strict: any key it does not recognise is an error, caught when the recipe loads. The recognised keys are exactly:
out_dir, env, wrap, source, delegate, source_package, the four target sections build, test, install, clean, and the source-generation section gen.
The one pekit itself ships with is deliberately minimal:
= "out"
[]
= "go build -trimpath -o \"$PEKIT_OUT/pekit\" ./cmd/pekit"
[]
= "go test ./..."
[]
= "go install ./cmd/pekit"
[]
= "go clean ./..."
A fuller recipe that fetches an external source and uses more of the surface:
# Where pekit stages its output. Managed by pekit; removable by `clean`.
= "out"
# Environment variables layered onto every target command.
[]
= "never"
# Wrapper: every target command runs inside this. {{command}} is substituted
# with the actual build script exactly once.
[]
= "nix develop --command sh -c {{command}}"
# Where the source tree comes from, and which versions exist.
[]
= "https://github.com/BurntSushi/ripgrep.git"
= "{{version}}"
= ">=13.0.0, <=14.1.1"
= '^(\d+\.\d+\.\d+)$'
# Borrow selected behaviour from a pekit.toml inside the fetched source.
[]
= true
# Named build targets. `needs` names other build targets to stage first.
[]
= "cargo build --release && cp target/release/rg \"$PEKIT_OUT/rg\""
[]
= "cp complete/_rg \"$PEKIT_OUT/_rg\""
= ["main"]
[]
= "cargo test --release"
= ["main"]
[]
= "cargo install --path . --root \"$PEKIT_OUT\""
[]
= "cargo clean"
The top-level keys break down as follows:
out_dir— the directory pekit stages everything under, relative to the recipe unless absolute. Defaults to"out". This is pekit's managed output:pekit cleanmay remove it wholesale, so keep nothing precious there. It holds fetched sources, per-target staging directories, and written.peipkgfiles.[env]— a table ofNAME = "value"pairs exported into every target command. Names must be valid shell identifiers, and pekit reserves thePEKIT_prefix — you cannot set aPEKIT_*variable here. See Environments and keyrings.[wrap]— a singlecommandthat every target runs inside. The string must contain exactly one{{command}}placeholder, which pekit replaces with the target's script. Use it to enter a toolchain shell, a container, or a sandbox.[source]— declares where the source tree comes from:[source.git],[source.url], or[source.local]. Absent, the recipe builds against its own directory. Covered in Sources.[delegate]— opt in to borrowingbuild,env,wrap, orpackagesbehaviour from apekit.tomlfound inside the fetched source tree (ordelegate = truefor all of it).[source_package]— opt out of, or rename, the corresponding-source package a recipe with a reproducible source emits alongside its binary packages. See Signing and provenance.[build]/[test]/[install]/[clean]— the target sections. See below.
Targets are shell commands #
Each of build, test, install, and clean defines one or more targets. A target is fundamentally a shell command plus a little scheduling metadata:
command(required) — the command to run. A string is executed withsh -euc(POSIX shell, with-eabort-on-error and-uunset-variable-is-error). An array of strings is executed directly as a program and arguments, without a shell, unless a wrapper is in play.needs— a list of other target names in the same section to stage first. Each named build a target needs is exposed to it asPEKIT_<NAME>_OUT(see below).clear_out— whether pekit wipes this target's staging directory before running (defaulttrue).dependencies— (build targets only) package dependencies to resolve and expose to the build. See Dependencies and claims.
A section can be written in bare form, with the keys directly under it (an implicit target named main):
[]
= "make"
…or in named form, one subtable per target:
[]
= "make"
[]
= "make man"
= ["main"]
The two forms cannot be mixed within one section: a bare [build] with a command may not also carry a [build.docs] subtable.
The PEKIT_* environment contract #
This is the key interface between pekit and your build scripts. Before running a target, pekit stages a directory for it and exports a set of PEKIT_* variables describing where everything is. Your command reads them as ordinary shell variables — $PEKIT_OUT, $PEKIT_VERSION, and so on. The build writes its artifacts into $PEKIT_OUT; that staged directory is what the package files later draw from.
The target command runs with its working directory set to the source root ($PEKIT_SOURCE_ROOT) — except clean, which runs in the recipe directory.
The variables a recipe author reaches for most are these — a subset of the full contract:
| Variable | Meaning | When set |
|---|---|---|
PEKIT_OUT | This target's own staging directory. Write your build output here. | Every target |
PEKIT_RECIPE_ROOT | Directory containing pekit.toml. | Every target |
PEKIT_SOURCE_ROOT | Root of the source tree the command runs in (its working directory). Equals the recipe root when there is no external source. | Every target |
PEKIT_VERSION (and _MAJOR, _MINOR, _PATCH, _PRERELEASE, _BUILDMETA) | The resolved version string being built, and its parsed components. | When a version is resolved (absent for clean, gen, and verify, which never resolve one) |
PEKIT_<NAME>_OUT | The staging directory of build target <NAME> named in this target's needs. <NAME> is upper-cased with - and . turned into _. | Per entry in needs |
The full contract — every managed variable, its exact value, and when it is set — is the environment contract in the command-line reference. It also covers the timestamp variables, the other root and output paths, and the PEKIT_DEPENDENCIES* variables that carry build dependencies into the command.
env.pekit.toml and keyring files layer more variables on top (PEKIT_KEYRING_*, plus any user env). User-set [env] values never override a PEKIT_* variable — that prefix is reserved.
Shell variables, not templates #
Inside a target command you use shell variables: $PEKIT_OUT, $PEKIT_VERSION. Pekit does not run its own templating over commands — they are handed to the shell verbatim (after the PEKIT_* exports are prepended). The {{...}} syntax you see in fields like source.git.ref = "{{version}}" is a separate, declarative template mechanism that applies to specific recipe fields, not to shell commands. Keep the two straight: declarative fields template with {{...}}; shell commands read $PEKIT_*. See Versions for the declarative side.
How pekit finds the recipe #
By default pekit walks up from the current working directory looking for pekit.toml, stopping at the first one it finds (the same upward search then looks for a workspace.pekit.toml above it). So running a pekit command anywhere inside a recipe tree finds the right recipe.
You can override this:
--recipe <path>points at a specificpekit.toml(or a directory containing one).- A remote locator — a
github.com/...path, a.gitURL, or ascheme://...URL, optionally with a@refand a//subdir— makes pekit clone the recipe from a git remote before running it.
The full flag and locator surface is on the invocation page.
Where to go next #
- Sources — the
[source]section in depth. - Package files — turning
$PEKIT_OUTinto.peipkgpayloads. - Recipe format reference — the exhaustive
pekit.tomlschema.
Sources
pekit / Writing recipes
A source tells pekit where a recipe's code comes from. Before any build
target runs, pekit resolves the source into a concrete source tree on
disk — the directory that targets execute in and that @source: package
references read from. This page covers the source
kinds, how each is materialised, and how a recipe can borrow behaviour from its
source tree.
Sources are declared under [source.*] in the recipe. A recipe may have:
- no external source (sourceless) — the recipe directory itself is the tree;
- one reproducible source — either
[source.git]or[source.url], never both; - optionally a local override —
[source.local], on its own or alongside a reproducible source.
A source is reproducible when it is git or url — those pin to exact
bytes. Declaring two reproducible tables is an error (mixed_source:
"exactly one reproducible source table is allowed").
The three source kinds #
[source.git] #
Clones a git repository and checks out a ref. It takes exactly four fields —
a required url (passed to git clone --mirror), a ref to check out
(templated with the selected version; defaults to
{{version}} and must render non-empty), a versions cap that filters
enumerated or requested versions, and a tag_regex used when enumerating tags
(see Enumeration). The field-by-field schema is in the
recipe format reference.
[]
= "https://github.com/example/widget.git"
= "v{{version}}"
= ">=2.0.0"
= "^v([0-9]+\\.[0-9]+\\.[0-9]+)$"
If ref renders empty (for example, a bare {{version}} with no version
selected) pekit fails with missing_version — pass --version or set a
non-templated ref.
[source.url] #
Downloads a file over HTTP(S), optionally extracting it as an archive. The
required url is templated with the selected version; extract (default
false) treats the download as an archive, with root naming the subdirectory
inside the archive to promote as the source tree; versions is a version
cap (as for git); file_regex is used when enumerating a URL listing; and
checksum pins the download to an expected digest — a single string or a
per-version table (see below). The full schema is in the
recipe format reference.
[]
= "https://ftp.example.org/widget/widget-{{version}}.tar.gz"
= true
= "widget-{{version}}"
= "sha256:0f1e2d..."
checksum is a sha256:<hex> string, and it is the only algorithm the
verifier accepts — any other prefix fails with "unsupported checksum spec". It
may instead be a table keyed by version:
[]
= "sha256:aaaa..."
= "sha256:bbbb..."
When the table form is used and the selected version has no entry, pekit fails
with missing_checksum. You rarely need checksum, though: every fetched
version is pinned automatically in the recipe's
lockfile, and a URL source is only materialised
unanchored — not pinned to a digest — when it has neither a checksum nor a
lock entry, which matters for
reproducible packaging.
A URL source can also carry a [source.url.signature] table pinning the
upstream maintainer's release-signing key: pekit fetches the detached
signature beside the artifact and verifies it before anything is locked or
built, closing the trust-on-first-use gap for brand-new versions. The full
schema is in the recipe format reference.
[source.local] #
Points at a directory already on disk. Its single field is a required path —
the directory to use as the source tree, resolved relative to the recipe
directory.
[]
= "../widget-checkout"
[source.local] is an override, not a reproducible source: a recipe
with only [source.local] cannot be pinned or enumerated. The local tree is
only used when a local flag selects it — see below.
Selecting the local override #
Two invocation flags choose the local override; they resolve differently and are mutually exclusive (passing both is an error).
--local(strict): require a local source. If neither[source.local]nor--local=<path>supplies a directory, pekit fails withmissing_local_source. Use this to force building from a working checkout.--prefer-local: use the local source if it is usable, otherwise fall back to the reproducible source. If the local tree is missing and the recipe has no reproducible source, the fallback fails.
Both accept an inline path:
--local/--prefer-localwith no=<path>uses the directory from[source.local](resolved relative to the recipe directory).--local=<path>/--prefer-local=<path>uses<path>resolved relative to the invocation's working directory (cwd), ignoring[source.local].
The chosen directory must exist and be a readable directory, or pekit errors.
Local sources require an exact --version — --latest, --all-versions,
and constraint selectors are rejected (unsupported_version_mode); with no
version, a local build uses the placeholder 0.0.0-localdev.
For a sourceless recipe, passing a local flag is an unsupported_flag
error — unless --allow-unused is set, in which case the flag is ignored with a
warning and the recipe stays sourceless.
Materialisation #
Resolving a source produces a source state: its kind, the source root (where targets run), a provenance ref, and a timestamp. What pekit does to produce the tree depends on the kind.
Sourceless (recipe). No work: the source root is the recipe
directory. Provenance is recipe:<recipe-root>.
Local (local). No copy or fetch: the source root is the local
directory itself; targets run in place. Provenance is local:<path>. Because
nothing is fetched, local sources are not cached and --refresh-source has no
effect on them.
Git (git). pekit keeps a bare mirror clone under
<out_dir>/_source_cache/git/<hash>/repo.git:
- If the mirror does not exist,
git clone --mirror <url>; otherwisegit fetch --prune --tagsto update it. A failed refresh is fatal only when nothing pins what the ref means: if the selected version is locked, the entry matches the rendered ref, and the pinned commit is already in the mirror, pekit warns and builds from the locked commit instead — so a rate-limited or unreachable upstream cannot block a locked build. Unlocked resolves still fail hard. - Resolve
refto an immutable commit (git rev-parse <ref>^{commit}). - When a version is selected, verify the resolved commit against the version's lockfile entry — or create the entry on first resolve.
- Clone the mirror into
<out_dir>/git-<hash>/source, thengit reset --hard <commit>andgit clean -fdxto pin the checkout. - Apply the recipe's patch series, when one is declared. Reset and clean restored the pristine tree, so the series re-applies on every resolve.
- Write a
source.pekit.jsonmanifest recording the URL, ref, resolved commit, and provenance.
Provenance is git:<url>@<commit> and the timestamp is the commit's committer
date, so a git build is anchored to a specific commit even when ref was a
branch or tag.
URL (url). pekit caches the downloaded artifact under
<out_dir>/_source_cache/url/<hash>/:
- If the artifact is not already cached, download it (with a
pekit/2user-agent). - If a
checksumis set, verify it; on mismatch, re-download once and verify again before failing. - Verify the artifact against the version's lockfile entry,
cache hits included — or create the entry on first resolve, verifying the
upstream signature first when
[source.url.signature]is configured. - Materialise the tree at
<out_dir>/url-<hash>/source: ifextractis set, extract the archive to a temporary directory, then promote therootsubdirectory (which must exist, elsemissing_source_root); otherwise copy the raw artifact into the source directory. Extraction preserves each regular file's archived mtime — release tarballs encode "generated outputs are newer than their inputs" in timestamps, and losing that fires autotools maintainer rebuild rules in environments without the maintainer tools. - Apply the recipe's patch series, when one is declared. The series' content hash joins the materialisation scope, so an edited patch lands in a fresh extraction.
- Write a
source.pekit.jsonmanifest.
The downloaded artifact is cached regardless. The extracted tree is re-materialised from that cached artifact on each run when a checksum is set (the pinned content is deterministic anyway); without a checksum the tree is reused as long as the recorded manifest still matches.
Caching and --refresh-source #
Pekit does not expose configurable cache policies. Caching is exactly the
behaviour above — a persistent raw cache (mirror repo / downloaded artifact)
plus a materialised tree — and the one knob is --refresh-source, which
discards the cache and rebuilds from scratch:
- git: removes the mirror repo and the checkout scope, forcing a fresh
clone --mirrorand checkout. - url: removes the cached artifact and the materialised scope, forcing a re-download and re-extract.
- local / sourceless: nothing to refresh.
A refresh does not relax the lock: the fresh download is still verified
against the lockfile, so --refresh-source is how you check upstream and
pekit lock --repin is how you accept a change.
The lockfile #
Fetched inputs are pinned trust-on-first-use in a machine-written
pekit.lock beside pekit.toml. The first time a source resolves for a
version, pekit records what it fetched — the artifact's SHA-256 for a url
source, the resolved commit for a git source — and every later resolve
verifies against that entry instead, cache hits included. A mismatch is a hard
lock_mismatch stop: upstream's published bytes (or a tag) changed under a
version that was already pinned. Accepting such a change is an explicit
ceremony, never automatic:
pekit lock --repin --version 1.2.0
Commit the lockfile with the recipe. Local sources, dry runs, and git sources
without a selected version (a bare branch ref is a deliberately moving target)
are never locked. The file's exact schema is in
Supporting files; the lock
command — including pre-locking versions without building — is in the
command-line reference.
Source packages #
A recipe with a reproducible source automatically emits a
corresponding-source package alongside its peipkg-format members — the
pristine upstream artifact, the patch series, and the recipe files, verifiable
against the committed pekit.lock — and every emitted manifest records the
provenance of its inputs, recipe, and builder. Both are covered in
Signing and provenance; opt out or
rename with the recipe's
[source_package] table.
Archive extraction #
Extraction is chosen by the artifact's filename suffix. Only these formats
are handled; anything else is an unsupported_archive error:
| Suffix | Handler |
|---|---|
.zip | native zip reader |
.tar | uncompressed tar |
.tar.gz, .tgz, .crate | gzip + tar (.crate is cargo's publish format — a plain gzipped tarball) |
.tar.bz2, .tbz2 | bzip2 + tar |
.tar.zst | zstd + tar |
.tar.xz, .txz | tar piped through the external xz -dc (requires xz on PATH) |
Extraction is hardened: entries that escape the extraction root (absolute
paths, .., or traversal through a symlinked parent), unsafe symlink targets,
NUL bytes, unsupported entry types, and colliding entries are all rejected as
unsafe_archive.
Patches #
A recipe can carry a patch series that pekit applies to the materialised
tree, declared as a directory name under [source]:
[]
= "patches"
The directory's series file lists patch files in apply order (one relative
path per line; # starts a comment). pekit applies them with git apply —
strictly, with no fuzz — immediately after the pristine tree is materialised
and before any target runs, so targets always see the patched tree and
@source: mappings package patched files. How that interacts with caching
follows the source kind:
- git sources re-apply the series on every resolve: the checkout is reset to the pinned commit and cleaned first, so an edited patch takes effect on the next run.
- url sources materialise once per patch-set content: the series' hash joins the materialisation scope, so an edited patch extracts and patches a fresh tree.
- local sources are never patched — a local tree is your own working state, often with the series already applied or mid-rework. pekit emits a warning and continues.
A failed hunk is patch_apply; a patch that matches nothing is
patch_skipped; either aborts materialisation and leaves no half-patched
tree behind. The series grammar and its validation errors are in
Supporting files.
Patches are committed recipe content, so the lockfile is
uninvolved: it pins fetched bytes, and the patch series is already under
version control. The whole directory ships in the recipe's
source package under patches/ — the shipped series is
the applied series by construction.
Rebasing on a version bump is deliberately manual: a stale patch fails
loudly, so materialise the new tree (pekit lock --version <v> is enough),
fix the patch, and run again.
Enumeration #
Reproducible sources can list the versions they offer upstream; this feeds
--latest, --all-versions, and constraint selectors (see
Versions).
- git:
git ls-remote --tags <url>, with tags optionally filtered bytag_regex. If the regex has a capture group, group 1 is the version; otherwise an embeddedMAJOR.MINOR.PATCHis extracted. - url: fetch the directory listing derived from the
urltemplate (the part before the first{{…}}, up to the last/), filter entries byfile_regex, and extract versions from the matches.
Local and sourceless recipes cannot enumerate
(version_enumeration_unavailable) — they require an exact version. The
versions field on a git or url source acts as a cap applied to the
enumerated (or requested) set; version selection itself is documented on the
Versions page.
Source roots and provenance #
The source root is the directory targets execute in and the base that
@source: file references resolve against. Every
resolution records a provenance ref identifying exactly what was
materialised:
| Kind | Provenance ref |
|---|---|
| sourceless | recipe:<recipe-root> |
| local | local:<path> |
| git | git:<url>@<commit> |
| url (checksummed) | url:<url>#<checksum> |
| url (locked, no checksum) | url:<url>#sha256:<hash> |
| url (no checksum, no lock entry) | url:<url> (marked unanchored — reachable only in a dry run, since a real resolve locks) |
For git and url sources the provenance is also written to a source.pekit.json
manifest beside the materialised tree, and pekit emits it as a source event
under --verbose.
Delegation #
A recipe can borrow behaviour from a pekit.toml that lives inside its
materialised source tree, using the [delegate] table (or the shorthand
delegate = true, which enables everything):
= true
# or, selectively:
[]
= true
= true
= true
= true
[delegate] has four surfaces plus all: build borrows the
build/test/install/clean targets from the source's recipe, env its
environment variables, wrap its command wrapper, and packages the package
definitions discovered in the source tree; all enables all four. (The key
table is in the recipe format reference.)
Delegation only happens when the source tree is a different directory from
the recipe (so a sourceless or in-place local recipe never delegates), and, for
build targets, only when a pekit.toml exists at the source root. The merge is additive with the base recipe winning: a
delegated target is adopted only if the base recipe does not already define a
target of the same kind and name, and adopted targets are tagged as owned by the
source. Env, wrap, and package delegation are likewise gated on their
corresponding [delegate] flags and only apply when the source tree differs
from the recipe root.
This lets a thin Peios recipe wrap upstream software that already ships its own
pekit recipe: point [source.git] at the upstream repo, set delegate = true,
and reuse its build and packaging while overriding only what you need.
Where to go next #
For how enumerated versions are selected and rendered, read Versions.
For turning the materialised tree into package payloads, read Packaging files.
For the exhaustive [source] schema, read Recipe format reference.
Versions
pekit / Writing recipes
A pekit build is almost always about a version of some upstream software. The
recipe declares where source of a given version comes from and how to name the
packages it produces; the invocation decides which versions are in scope. This
page covers the version grammar pekit accepts, the {{...}} variables that carry
version components into your recipe, and how the --version, --latest, and
--all-versions selectors resolve against a source.
Version forms #
A version is written as:
MAJOR[.MINOR[.PATCH]][-PRERELEASE][+BUILDMETA]
Only MAJOR is mandatory. Minor and patch are each optional but ordered — you
cannot write a patch without a minor. The optional -PRERELEASE and +BUILDMETA
tails may each contain digits, ASCII letters, dots, and hyphens.
| Written version | major | minor | patch | prerelease | buildmeta |
|---|---|---|---|---|---|
2 | 2 | ||||
2.43 | 2 | 43 | |||
2.43.1 | 2 | 43 | 1 | ||
1.21.0-rc.1 | 1 | 21 | 0 | rc.1 | |
1.21.0+build.5 | 1 | 21 | 0 | build.5 | |
1.21.0-rc.1+bld | 1 | 21 | 0 | rc.1 | bld |
Anything that does not match this grammar is rejected with an invalid_version
error. Comparison and ordering are numeric on major/minor/patch (a missing
component counts as 0), with the prerelease string compared lexically to break
ties.
Version template variables #
Six variables expose the parsed components of the selected version. They render
anywhere pekit expands {{...}} templates in a recipe — most importantly in
source refs and URLs, and in
package version and metadata fields. (These six are
the version-derived subset of the template engine; the complete variable table,
including the multi-package-only {{multipack}}, is in
Supporting files.)
| Variable | Expands to |
|---|---|
{{version}} | the version exactly as written |
{{major}} | the major component |
{{minor}} | the minor component |
{{patch}} | the patch component |
{{prerelease}} | the prerelease tail, or empty |
{{buildmeta}} | the build-metadata tail, or empty |
[]
= "https://github.com/example/tool.git"
= "v{{version}}"
Two rules govern rendering:
- Referencing a component the version does not have is an error.
{{version}}fails when no version is selected at all;{{minor}}and{{patch}}fail when the selected version stops short of that component (for example{{patch}}against2.43).{{prerelease}}and{{buildmeta}}are the exception — they render as empty text when absent rather than erroring. - Unknown variables are an error. Any
{{name}}that is not one of the six above (or the multi-package{{multipack}}token) is rejected. There is no silent pass-through.
Selecting versions #
Three mutually exclusive flags choose the version set. Supplying more than one is
rejected with --version, --latest, and --all-versions are mutually exclusive.
| Flag | Selects | Needs an enumerable source? |
|---|---|---|
--version <selector> | exact version(s), or a semver constraint | only for constraints |
--latest | the single highest available version | yes |
--all-versions | every available version | yes |
| (none) | an unversioned build (no {{version}} available) | no |
Enumerable sources are those pekit can list versions for: a [source.git]
source (via git ls-remote --tags) or a [source.url] source (by fetching the
listing directory). --latest, --all-versions, and any constraint require one;
against a non-enumerable source they fail with selected source cannot enumerate versions.
--version as an exact selector #
The simplest form is one or more exact versions, comma-separated:
pekit build tool --version 2.43.1
pekit build tool --version 2.43.1,2.42.0
A comma-separated list without spaces is treated as a set of exact versions. When the source is reproducible and enumerable, each written version is matched against the versions the source actually publishes using the trailing-zero ladder described below.
--version as a constraint #
A --version value that contains a space or a comparison operator
(<, >, =, <=, >=) is treated as a constraint and takes the
enumeration path. Constraints filter the enumerated set:
pekit build tool --version ">= 2.40"
pekit build tool --version ">= 2.40, < 3.0"
Multiple constraints (comma- or space-separated) are ANDed together. The ~ and
^ operators are not supported and produce
unsupported version constraint operator. A bare * matches everything.
--latest and --all-versions #
pekit build tool --latest # highest available version only
pekit package tool --all-versions # every available version
--latest picks the single greatest version after enumeration and filtering.
--all-versions selects the whole set.
The trailing-zero ladder #
Upstreams are inconsistent about trailing zeros: one project tags v2.43.0,
another tags v2.43, a third tags v2. So when you ask for an exact version
against a reproducible, enumerable source, pekit does not demand a character-exact
tag. It builds a ladder of candidates from your written version, most specific
first, and picks the first candidate the source actually offers:
| You write | Candidates tried, in order |
|---|---|
2.43.0 | 2.43.0, then 2.43 |
2.0.0 | 2.0.0, then 2.0, then 2 |
2.0 | 2.0, then 2 |
2.43.1 | 2.43.1 only |
Only trailing zero components are dropped — a non-zero patch or minor is never
elided. The ladder is also skipped entirely when the version carries a
prerelease or build-metadata tail: 1.21.0-rc.1 is only ever matched as written.
If no candidate is available (or the source is not reproducible/enumerable), the
version you wrote is used verbatim.
The ladder applies to the exact-selector path only. --latest,
--all-versions, and constraints work on the enumerated set directly and do not
ladder.
Source version caps #
A source can declare a hard ceiling (or floor) on the versions a recipe will ever
build, independent of what the invocation asks for. Set versions under the
source table:
[]
= "https://github.com/example/tool.git"
= "v{{version}}"
= ">= 1.0, < 3.0"
The cap uses the same constraint grammar as --version. It is applied on both
paths:
- On the enumeration path (
--latest,--all-versions, constraints) the cap filters the available set before the selector runs, so--latestreturns the highest version within the cap. - On the exact-selector path the cap filters the versions you named. If every
named version falls outside the cap the build fails with
selected exact versions were filtered out by source version cap.
An empty result after capping is always an error — a cap that excludes everything is treated as a misconfiguration, not a no-op.
Local builds #
When the source resolves to a local directory, there is
nothing to enumerate. Local sources therefore require an exact --version;
--latest, --all-versions, and constraints fail with local sources require an exact --version.
If you build locally without giving a --version at all, pekit stamps the build
with the sentinel version 0.0.0-localdev so that {{version}} and the
PEKIT_VERSION* variables still resolve. This makes local development builds work
without inventing a version number, while keeping them clearly distinguishable
from real releases.
Single-version commands #
build, package, and publish operate on many versions at once — pass
--all-versions and pekit plans one build per version.
test and install do not. They require the selector to resolve to exactly
one version; if it resolves to more, the command fails with test requires a single resolved version (or install requires ...). Use an exact --version or
--latest for these commands.
Where to go next #
For where a selected version's source tree comes from, read Sources.
For how version components render into package names and metadata, read Packaging files.
For the full flag surface behind --version, --latest, and --all-versions, read Invocation and flags.
Packaging files
pekit / Writing recipes
A package definition answers one question: which files, from where, go where in
the package? Everything else — metadata, dependencies, claims, multi-package
fan-out — hangs off that payload. This page is about the payload itself: the
[files] and [symlinks] tables, how source references resolve, and the rules
that decide the final layout of the .peipkg.
Package definitions live in their own files, not in the recipe:
package.pekit.toml (the base definition) and <name>.package.pekit.toml
(named members). How those layer together, and how [multipack] expands one
definition into many packages, is covered in
Multi-package recipes. Manifest metadata,
dependencies, and claims are covered in
Dependencies and claims. This page
assumes a single definition and concentrates on the file mapping.
The [files] table #
[files] maps sources to package destinations. The mapping direction is
source on the left (the key), package destination on the right (the value):
[]
= "usr/bin/hello"
= "etc/hello/hello.conf"
The left side is a source ref naming what to copy; the right side is the path the file takes inside the package. Destinations are always relative (see Destination rules below).
Each value may also be a table, which adds an override flag:
[]
= { = "usr/share/hello/layout.json", = true }
path is the package destination (required; an empty destination is an error),
and override — default false — exempts the entry from the package format's
layout policy (see override). The bare-string form
"src" = "dest" is exactly "src" = { path = "dest" }. The field-by-field
schema is in the recipe format reference.
Source refs #
A source ref names where a file comes from. There are three families.
Build-output refs — target:path #
A ref containing a colon is a build ref. The part before the colon is a build target name; the part after is a path inside that target's staged output:
[]
= "usr/bin/hc" # from build target "tools"
= "usr/bin/hello" # empty target = "main"
:path is shorthand for main:path — an empty target name resolves to the
target main. Referencing a build target in [files] automatically pulls that
build into the package's build plan, so you do not have to list it separately.
Rooted refs — @source:, @recipe:, @workspace: #
A ref beginning with @<root>: is anchored to a named root rather than a build
output:
| Prefix | Root |
|---|---|
@source: | The materialised source tree (the checked-out / extracted source). |
@recipe: | The directory containing the recipe (and its package files). |
@workspace: | The workspace root. Used outside a workspace, this is an error (missing_workspace). |
[]
= "usr/share/licenses/hello/LICENSE"
= "usr/lib/systemd/system/hello.service"
The reference table of source-ref roots gathers every ref form — build refs, the rooted prefixes, and bare paths — in one place.
Plain paths are owner-relative #
A ref with no prefix and no colon is a plain path — and its meaning is set by the file that declared it, not by any global default. At decode time, pekit rewrites each plain path to a rooted ref based on the file's owner:
| The ref was declared in… | A plain path becomes… |
|---|---|
| a recipe-side package file | @recipe: |
| a delegated source-side package file | @source: |
| a workspace-side package file | @workspace: |
So in a recipe's own package.pekit.toml:
[]
= "usr/share/doc/hello/README.md"
"README.md" is normalised to "@recipe:README.md" — it resolves against the
recipe directory. The identical line in a source-delegated package file would
normalise to "@source:README.md" and resolve against the source tree instead.
This normalisation is what lets the same package file mean the right thing
whether it ships beside the recipe or is discovered inside a delegated source.
Refs that are already rooted (@…:) or already build refs (contain a :) are
left untouched by this rewrite.
Path cleaning #
The path portion of every rooted ref is cleaned: . and duplicated slashes are
collapsed, and a path that escapes its root with .. (or is absolute) is
rejected:
invalid_ref: "@recipe:../secrets" escapes its root
Globs #
A source ref's path may contain glob metacharacters — *, ?, or a [...]
character class — and ** for recursive matching:
[]
= "usr/share/hello/icons/"
= "usr/lib/"
Rules:
-
Matching happens only within the ref's source root. A glob never reaches outside the root it is anchored to.
-
Matches are sorted, and a match already covered by a matched parent directory is dropped (the directory carries its contents).
-
By default a glob that matches nothing is an error:
missing_payload: file source /…/share/icons has no matches -
Placement is relative to the non-glob prefix. Everything up to the first segment containing a glob metacharacter is the base; matched paths are re-rooted under the destination from there. So
share/icons/*.png→usr/share/hello/icons/placesfoo.pngatusr/share/hello/icons/foo.png, andlib/**/*.sopreserves the sub-directory structure belowlib/under the destination.
A plain (non-glob) ref that names a directory copies the whole tree; empty directories are emitted as explicit entries (so a pure directory skeleton is not lost).
Excludes #
excludes prunes files after expansion. Each entry uses the same source-ref
syntax as [files] (and is owner-normalised the same way), so an exclude is
scoped to a root:
[]
= "usr/share/hello/"
= [
"@source:share/**/*.tmp",
"@source:share/internal/**",
]
An exclude is applied only to expansions that share its root; it is matched
against each candidate with the same glob engine. Unlike [files], an exclude
that matches nothing is allowed — it is a filter, not a required input.
Destination rules #
The right-hand side of a [files] entry (and every symlink destination) is a
path inside the package. It must be relative and stay in-bounds:
- No leading
/and no..— an absolute or escaping destination is rejected. - No NUL bytes; an empty destination is an error.
.segments and duplicate slashes are normalised away.
Directory vs single-file destinations #
For a single, non-glob match, the trailing slash on the destination decides the shape:
| Ref | Destination | Result |
|---|---|---|
":bin/hello" | "usr/bin/hello" | Renamed to usr/bin/hello. |
":bin/hello" | "usr/bin/" | Placed as usr/bin/hello (basename kept). |
For a glob or a multi-match source, the destination is always treated as a directory prefix and matched paths are re-rooted beneath it.
Collisions are errors #
Two payload inputs that resolve to the same package path is a hard error — pekit will not silently let one input overwrite another:
payload_collision: multiple package inputs map to usr/bin/hello: …
(Sibling checks catch two package instances writing the same artifact file, or emitting the same package name.)
override #
override = true exempts an entry from the package format's layout policy
only. The peipkg format validates that non-override payload paths obey its
layout rules; an override entry skips that validation, letting you place a file
somewhere the policy would otherwise reject.
Override does not relax the safety rules above: destinations are still
cleaned, still may not escape the package with .., and collisions are still
errors. It only opts out of format-level layout policy.
[symlinks] #
[symlinks] adds symbolic links to the payload. Here the key is the package
destination (where the link lives) and the value is the link's target text:
[]
= "hello"
= { = "libhello.so.1", = true }
The target text is stored literally — it is the exact string the symlink
points at and is not resolved through any source root. It may be relative or
absolute as a link target; pekit only checks that it is non-empty and contains
no NUL. The bare-string form is "dest" = "target"; the table form adds
override, which behaves as for files.
The destination key follows the same destination rules as
[files].
The result #
Packaging stages the resolved payload and writes one package artifact per
package instance under the recipe's output directory (out_dir). The format is
chosen by the package's format (defaulting to tar); a peipkg package emits
a file named <name>_<version>_<architecture>.peipkg, with the name,
architecture, and other manifest fields coming from [package]. A peipkg
package must set [package].version, [package].architecture, and
[package].license; a tar artifact carries payload only and is normalised
for determinism (entry timestamps zeroed, ownership dropped).
The .peipkg on-disk format itself is documented in
Package management.
Where to go next #
For the rest of the manifest — dependencies, provides, claims — read Dependencies and claims.
For splitting one recipe into several packages, read Multi-package recipes.
For the exhaustive key-by-key schema, read the recipe format reference.
Dependencies and claims
pekit / Writing recipes
A finished .peipkg carries a manifest that says what the package needs (its
dependencies), what shared names it fills, and how it plugs into names other
packages share. Pekit builds that manifest from three sources, in order of
increasing automation:
- Declared package dependencies —
[dependencies]and[optional_dependencies]you write in a package file. - Derived dependencies — sonames and pkgconfig capabilities pekit reads out of the staged binaries at pack time, added on top of what you declared.
- Claims — the
[claims]table, wiring a package's provides and dependencies into the peipkg "exactly one holder of a shared name" mechanism.
A separate, unrelated mechanism — build dependencies exported to the build command — is also covered here because it shares the word "dependency". It never touches the package manifest.
Declared package dependencies #
Package dependencies live in a package file (package.pekit.toml or a
<name>.package.pekit.toml member), not in the recipe. Two tables produce them:
[dependencies] for hard requirements and [optional_dependencies] for soft
ones. Both parse identically.
[]
= ">= 3.0"
= "*"
= ">= 1.2.11"
[]
= "*"
Each entry maps a name to a version constraint string. The name is a
peipkg capability name — either a real package name or a virtual capability such
as a soname (libc.so.6) or a pkgconfig(...) token. The constraint is any
peipkg constraint expression (">= 1.2", "< 2", and so on). Both "*" and the
long form below with the constraint omitted mean any version.
The table form: placing a dependency in a named root #
An entry may instead be a table, which is the only way to attach a placement root — the named root the dependency should be installed into, when it differs from the depender's own root:
[]
# short form: constraint only, depender's own root
= ">= 1.2"
# table form: constraint + explicit root
[]
= ">= 3"
= "system.lib"
A dependency table accepts exactly two keys, constraint and root; any other
key is an error. Omitting constraint matches any version. The root is a
named root reference (dotted lowercase segments, e.g. system.lib), never a
filesystem path — a / in it is rejected at build time. Roots are not carried
for conflicts, which stay root-local. There is no in-string IN root sugar;
the table is the only spelling.
These entries become the manifest's Dependencies and OptionalDependencies.
How they are matched to concrete packages at install time — capability lookup,
version satisfaction, root placement — is
dependency resolution in
peipkg; pekit only records the requirement.
Derived dependencies: ELF and pkgconfig scanning #
At pack time, after staging the payload and before writing the archive, pekit scans the files it is about to pack and adds derived provides and dependencies on top of whatever the package declared by hand. Two derivations run, both operating purely over the staged files:
- ELF derivation — reads the built binaries and shared objects.
- pkgconfig derivation — emits
pkgconfig(<name>)capabilities from any.pcfiles in the payload.
The results are merged additively: a derived provide or dependency whose name
already appears in the hand-declared set is skipped, so declaring something by
hand always wins. Non-fatal problems (see below) are printed to stderr as
warning: lines; they do not stop the build.
What ELF derivation detects #
For every ELF object in the payload, pekit reads two dynamic-section fields:
| ELF field | Becomes | Meaning |
|---|---|---|
DT_SONAME | a provides entry | the object's own ABI name, e.g. libfoo.so.3 |
DT_NEEDED | a dependency entry | each shared library the object loads |
The whole soname is the capability name. ABI-version identity lives in the
string and is matched by equality, so a dependency on libfoo.so.3 is never
satisfied by libfoo.so.4 — a soname bump is a different capability.
Two refinements keep the result accurate:
- Self-provided sonames are subtracted from the needs. A package that ships both a library and something that links against it does not end up depending on itself.
- Symlinks are skipped. A
-develpackage'slibfoo.so -> libfoo.so.3development symlink is not opened; following it would read the target'sDT_SONAMEand mis-attribute the provide to the package shipping the alias. The real object is derived where it actually lives.
If a file whose name looks like a shared library (lib*.so, lib*.so.N…)
carries no DT_SONAME, pekit warns that "nothing can depend on it" — such a
library is unusable as a dependency target.
Derivation is entirely automatic and takes no configuration beyond the
symbol-version policy below. In particular it has nothing to do with the
dependency_provider / PEKIT_DEPENDENCIES* mechanism described later — that is
a different feature that shares a word.
Symbol version policy #
By default a soname dependency is derived at soname granularity only: the
name must match exactly, with no version constraint. That is always safe but
coarse — it cannot express "needs at least the glibc that introduced
GLIBC_2.34".
A workspace may opt specific sonames into finer, version-aware derivation
through the [policy.symbol_versions] table in the workspace file. It maps a
soname to the symbol-version token prefix whose tokens are numbered on the
same scale as the providing package's version:
# in the workspace file
[]
= "GLIBC_"
= "FOO_"
For a soname listed here, ELF derivation is refined on both sides:
- Consumer side — pekit reads the object's versioned symbol needs, takes the
highest token carrying the prefix (e.g.
GLIBC_2.34), and turns it into a>= 2.34constraint on that dependency. Reserved, non-numeric tokens such asGLIBC_PRIVATEare ignored. - Provider side — the soname's own provides entry is stamped with the building package's own version, so consumers have something to match against.
A soname absent from the policy is derived at soname granularity only, as
above. The policy is a distro-wide assertion and lives only in the workspace
file — [policy] currently accepts just the one sub-table, and any other key
under it is an error. Recipes and package files cannot set it. When no workspace
policy is configured, every soname is derived at name granularity.
Build dependencies exported to the build command #
Distinct from everything above, a build target in a recipe may declare
[build.<target>.dependencies.<provider>] — a set of dependencies grouped under
one or more provider names. These never enter the package manifest. Instead,
pekit renders them and hands them to the build command through the environment,
so a build script can fetch or pin its build-time inputs.
[]
= "make"
[]
= ">= 13"
= "*" # "*" means any version; a build dep constraint may not be empty
[]
= "*"
dependencies is only valid on build targets. Each provider block maps a
capability name to a non-empty constraint (use "*" for any). Names and
constraints are templated, so {{version}} and the other version variables are
available.
Which provider is active is chosen by the dependency_provider key in the
selected environment file:
# in env.pekit.toml (or a named <env>.env.pekit.toml)
= "peipkg"
For every target run, pekit writes a JSON payload to
$PEKIT_ROOT/.pekit/dependencies/<command>.<target>.json and exports three
variables into the build environment (with no provider selected,
PEKIT_DEPENDENCY_PROVIDER and PEKIT_DEPENDENCIES are empty strings and the
payload still carries all_providers):
| Variable | Contents |
|---|---|
PEKIT_DEPENDENCY_PROVIDER | the selected provider name |
PEKIT_DEPENDENCIES_FILE | absolute path to the JSON payload |
PEKIT_DEPENDENCIES | the selected provider's deps, one name constraint per line |
These three are part of pekit's managed environment; the full environment contract lists every exported variable.
The JSON payload carries the command, target, provider, the resolved
version, the selected provider's dependencies, and all_providers (every
declared provider block, so a script can inspect alternatives). If
dependency_provider names a provider that a target with dependency blocks
does not define, that target's run fails (missing_dependency_provider); a
target with no dependency blocks at all ignores the selection. If no provider
is selected, the exported dependency list is empty but the payload still
lists all_providers.
Pekit does not resolve or install these — it only renders the declared list and
passes it through. Consuming it is the build script's job. dependency_provider
is a name selector, not a command or hook.
Claims #
A claim is how a package plugs into a shared filesystem name that at most one
installed package may hold at a time — the peipkg mechanism where several
packages can supply a name like /usr/bin/cc, but exactly one holder owns
the real file and the rest defer. See
claims for what this means at install time.
A package declares claims in the top-level [claims] table of a package file.
It has two sides:
[claims.provides]— the provider side: slots a[provides]entry fills.[claims.dependencies]— the consumer side: slots a dependency expects.
Under each side, the next key is the name of the provides or dependency entry the claim attaches to, and under that is one or more freely-named slots. Each slot is a table with just two possible fields:
| Field | Set by | Meaning |
|---|---|---|
target | provider | the holder file this package ships that the shared name resolves to |
path | consumer (or a provider default) | the shared filesystem location — where the claim symlink is materialised |
A worked example — a compiler package that offers to hold /usr/bin/cc, and a
consumer that expects cc to exist:
# provider: gcc.package.pekit.toml
[]
= "1"
[]
= "/usr/bin/gcc" # a file this package ships
= "/usr/bin/cc" # provider default for the shared name
# consumer: some-toolchain.package.pekit.toml
[]
= "*"
[]
= "/usr/bin/cc" # the shared name this package needs present
Claim path and target values are run through the template engine, so a
recipe may parameterise them (an arch triplet in a library path, for instance)
like any other manifest value.
At pack time pekit validates the provider side: every provider slot's target
must name a file the package itself ships. A target pointing outside the
package's own payload is a packaging error — the materialised symlink would
dangle or point at another package's file — and all offenders are reported
together. Consumer-side slots and provider slots that set only a path own no
target and are exempt from this check.
Where to go next #
For the package files these tables live in, read Packaging files.
For the environment file that selects a build dependency provider, read Environments and keyrings.
For the workspace [policy] table that refines soname derivation, read Workspaces.
Multi-package recipes
pekit / Writing recipes
A recipe rarely produces exactly one artifact. The same build tree is often
split into a runtime package, a -doc package, and a -dev package; a single
build sometimes fans out into one package per locale, per font family, or per
plugin. pekit expresses both shapes without repeating the build: member
package files split one definition into several named packages, and the
[multipack] block expands a single definition into many instances.
This page covers where package definitions live, how their layers merge, how you select which packages a run emits, and how multipack fan-out works.
Package files and layers #
pekit discovers package definitions from files whose names follow a fixed convention, relative to a root (the recipe directory, and — under a workspace or a delegated source — the workspace and source directories too). Discovery recognises two kinds of file:
| Kind | Location | Selector |
|---|---|---|
| Base | package.pekit.toml, or packages.pekit/package.pekit.toml | main |
| Member | <name>.package.pekit.toml, or packages.pekit/<name>.package.pekit.toml | <name> |
Both the root itself and a packages.pekit/ subdirectory are searched, so you
can keep package files beside the recipe or gather them in one folder.
- At most one base may exist. Defining
package.pekit.tomlin both the root andpackages.pekit/is an error (duplicate_package_base). - A member's selector is its filename with the
.package.pekit.tomlsuffix removed. A file literally namedpackage.pekit.tomlis the base, never a member; an empty member name is skipped. Each member name must be a valid selector, and a name may not be defined twice across the two search locations (duplicate_package).
Every file becomes a layer. Each layer records its owner — recipe,
workspace, or source — which fixes how bare payload references in that file
are qualified: an unqualified file source or exclude in a recipe-owned layer is
read as @recipe:, in a workspace-owned layer as @workspace:, and in a
source-owned layer as @source: (already-qualified refs and target:path
build refs are left untouched). See Package sources and
refs for the ref grammar.
How many packages a recipe emits #
The base file is a shared foundation, not a package of its own once members exist. The rule is:
- No member files — the base alone produces a single package with
selector
main. (With no base and no members at all, there is nothing to build:missing_package.) - One or more member files — the recipe emits one package per member.
The base is merged underneath every member as shared defaults; it does not
additionally produce a standalone
mainpackage.
So to emit a runtime package plus a -doc package you write two member files;
the base carries what they share.
Merge and precedence #
For each emitted package, pekit stacks the applicable layers and merges them in order, later layers overriding earlier ones. The order is:
- workspace base
- source base (only when the recipe delegates packages to a materialised source and the source root differs from the recipe root)
- recipe base
- source member(s) with this selector
- recipe member(s) with this selector
Merge is field-level, not deep, and two fields behave differently:
- Scalars and metadata (
format,clear_out,[package]fields such asversion,architecture,license,homepage,default_root) override only when the later layer sets them; an unset field leaves the earlier value in place. builds,[files],[symlinks],excludes,[multipack],[publish], and the constraint maps (dependencies,provides, …) are replaced wholesale when the later layer defines them. In particular a member's[files]table does not merge key-by-key with the base's — if the member declares[files]at all, it replaces the base's[files]entirely. Put shared payload in whichever single layer owns it.
One special case governs names: a member that does not set its own
[package].name does not inherit the base's name. Its name is cleared, which
lets it fall back to the selector-derived default (see
Instance naming below). The base's
[package].name therefore only ever names the lone main package.
Selecting packages on the CLI #
Two commands select packages: package (stages the builds a package needs,
then writes its artifacts under out_dir) and publish (does the same,
then copies each artifact to a configured localdir). See
Commands and targets.
Selection works like target selection:
- Positional selectors name members:
pekit package hello hello-doc. - No selector, one package — that package is used.
- No selector, several members — the run is ambiguous and pekit lists the
members (
ambiguous_package). --allselects every member.
--all cannot be combined with positional selectors; doing so is rejected
before any work runs:
invalid_flags: --all cannot be combined with package selectors
An unknown selector reports the available members (missing_package). A
selector may carry an :instance suffix to pick a single multipack instance —
covered next.
Multipack: one definition, many instances #
A single package definition fans out into several packages with the
[multipack] block. Each expansion is an instance, identified by a
multipack value; the value is available to the definition through the
{{multipack}} template variable. There are two enumeration modes.
Static enumeration #
List the values inline. [multipack] accepts exactly one key, enum; as an
array it is a fixed list of values (each a valid selector, no duplicates, and
the list may not be empty):
[]
= "fonts-{{multipack}}"
= "{{version}}"
= "any"
[]
= ["serif", "sans", "mono"]
[]
= "usr/share/fonts/{{multipack}}/"
This one definition emits three packages: fonts-serif, fonts-sans, and
fonts-mono, each packing its own subtree.
Enumeration from files #
To derive the value set from what the build actually produced, give enum a
files sub-table with a path glob and a regex (both required):
[]
= "locale-{{multipack}}"
= "{{version}}"
= "any"
[]
= "build:usr/share/locale/*"
= '^(?P<value>[a-z]{2})$'
[]
= "usr/share/locale/{{multipack}}/"
pekit globs path, applies regex to each match's basename, and takes the
captured text as the multipack value. The regex must expose the value through
either a named (?P<value>…) group or a single unnamed capture group; anything
else is rejected (invalid_regex). Values are de-duplicated and each must be a
valid selector. If the glob resolves but nothing matches, that is
empty_multipack.
Note the phasing: the path template is rendered with {{version}} only —
{{multipack}} is not available while enumerating, because that is the very
value being discovered.
The {{multipack}} variable #
{{multipack}} is available only in the package-instance phase — once a
concrete instance and its value exist. That covers the rendered manifest
metadata ([package] name, version, description, dependencies, provides,
claims, …), the [files] source and destination templates, [symlinks], and
the [publish] destination. Using {{multipack}} anywhere it is not bound —
including a non-multipack definition or the enum.files path — fails:
{{multipack}} is not available in this context
{{version}} and its parts are available throughout; see
Versions.
Selecting one instance #
A positional selector may target a single instance with member:instance:
pekit package fonts:serif # only fonts-serif
pekit package fonts # all instances of fonts
The :instance suffix is valid only on a multipack definition; applying it to
an ordinary package is an error, as is naming an instance the enumeration never
produces (missing_package).
Instance naming and artifacts #
Each instance is named from its rendered [package].name. When a definition
has no name, the name defaults to the member selector, and for a multipack
instance the value is appended: a nameless multipack member plugins with value
png becomes plugins-png. The artifact filename follows the format:
<name>_<version>_<arch>.peipkg for format = "peipkg", or <name>.tar for a
plain tar package.
Across everything a single run emits, pekit checks that no two instances write
the same artifact path (artifact_collision) or the same
name/version/architecture (package_name_collision).
Dry runs and unresolved instances #
--dry-run plans without executing. Because a dry run does not actually run the
build targets, a multipack that enumerates from build output (an
enum.files path pointing at a build: ref) has nothing to glob yet: its
instances cannot be resolved before the build exists. Rather than fail, pekit
reports each such definition as unresolved:
unresolved fonts multipack enumeration found no instances for build:...
A dry run whose only shortfall is unresolved instances still succeeds — the enumeration will resolve for real once the build runs. (Similarly, when package discovery is delegated to a source that has not been materialised, a dry run reports the packages as unresolved instead of erroring.) A real run performs the builds first, so enumeration sees the produced files.
Worked example: runtime plus docs #
A recipe that emits two packages from one build, sharing metadata through the base:
packages.pekit/package.pekit.toml — the shared base:
= "peipkg"
= ["main"]
[]
= "{{version}}"
= "x86_64"
= "MIT"
= "https://example.org/hello"
packages.pekit/hello.package.pekit.toml — the runtime member:
[]
= "hello"
= "The hello program"
[]
= "usr/bin/hello"
packages.pekit/hello-doc.package.pekit.toml — the docs member:
[]
= "hello-doc"
= "Documentation for hello"
[]
= "usr/share/doc/"
pekit package --all writes both hello_<version>_x86_64.peipkg and
hello-doc_<version>_x86_64.peipkg, each inheriting format, version,
architecture, license, and homepage from the base. pekit package hello
writes only the runtime package.
Where to go next #
For the [files], [symlinks], and metadata fields each member defines, read Packaging files.
For the package and publish commands and their selectors, read Commands and targets.
For driving the packages of many recipes at once, read Workspaces.
Environments and keyrings
pekit / Writing recipes
Every target pekit runs — a build, test, install, or clean command —
executes inside an environment pekit assembles for it. That environment has three
distinct layers: the managed PEKIT_* variables pekit sets automatically, the
keyring values that carry secrets and keys, and the user variables you
declare in [env] and env files. This page describes how those layers are built,
how they compose and override, how [wrap] wrappers change the way the command is
launched, and how keyrings resolve and inject values.
For the exhaustive list of managed PEKIT_* variables and what each one means, see
the environment contract in the command-line reference;
Recipe anatomy walks through the ones you will use most.
This page covers everything you add on top of that contract.
The three layers #
When a target runs, pekit composes three maps and overlays them in a fixed order:
| Layer | Source | Naming |
|---|---|---|
| Managed | Set by pekit (recipe/source roots, output paths, version parts, dependency outputs) | PEKIT_* |
| Keyring | Keyring files and --keyring.x.y= literals | PEKIT_KEYRING_* |
| User | [env] in the workspace, recipe, and selected env file | Any valid shell name |
The overlay order is managed, then keyring, then user — so in principle a later layer overwrites an earlier one on a key collision. In practice the three namespaces are kept disjoint by hard rules:
- User
[env]may not set any variable beginning withPEKIT_. Doing so is areserved_enverror. - A keyring export that collides with a user
[env]variable or with a managed variable is anenv_collisionerror, not a silent override.
So the layers never actually collide: managed owns PEKIT_*, keyrings own
PEKIT_KEYRING_*, and the user layer owns everything else.
The [env] block #
[env] declares plain environment variables as a TOML table. Each key is the
variable name and each value is a string:
[]
= "clang"
= "-O2 -pipe"
= "/usr"
Keys must be valid environment-variable names — they match
^[A-Za-z_][A-Za-z0-9_]*$. A non-string value, or a name outside that grammar, is
a parse error. Declaration order is preserved from the file.
[env] may appear in three places, each contributing to the user layer:
- the workspace file (applies to every member),
- the recipe (
pekit.toml), - the selected env file (see below).
Composition and override #
Within the user layer the sources are applied in this order, and a later source overrides an earlier one on the same variable name:
- workspace
[env] - delegated source recipe
[env], then that source's env file (only when the recipe delegates env — see Recipe anatomy) - recipe
[env] - the selected env file's
[env]
So a variable set in the recipe overrides the same variable inherited from the workspace, and the selected env file has the final say.
Shell expansion of [env] values #
For the common case — a shell target, or any target run under a [wrap] wrapper —
pekit emits the user variables as export lines in a shell script that runs before
your command, and it does not quote them. That means [env] values are
expanded by the shell and may reference other variables:
[]
= "$PEKIT_OUT/bin:$PATH"
= "$PEKIT_OUT/lib"
Managed PEKIT_* and keyring PEKIT_KEYRING_* values are exported before the
user block, so [env] can build on them. (A bare argv command — an array-form
command with no wrapper — receives the composed environment directly from the
process, where these values are literal rather than shell-expanded. Keep [env]
values literal if your target uses the array form without a wrapper.)
Env files and --env #
An env file is a supporting file that lives next to the recipe and carries an
[env] block, a [wrap] wrapper, a dependency_provider, or any combination of
the three. It lets you keep environment- or profile-specific settings out of the
recipe proper. An env file must declare at least one of those keys; the only
recognised top-level keys are env, wrap, and dependency_provider.
Which env file is loaded is controlled by --env <name>. The default is main,
so env.pekit.toml is picked up automatically when present and silently skipped
when absent. Passing --env none disables env-file loading entirely. Any other
name selects a named profile such as release.env.pekit.toml (--env release),
which must exist — a missing named file is an error.
# release.env.pekit.toml
= "peipkg"
[]
= "-O3 -DNDEBUG"
For the exact env-file schema and the selection table, see Supporting files.
[wrap] wrappers #
A wrapper changes how the target command is launched: instead of running your
command directly, pekit substitutes it into a wrapper command at a {{command}}
placeholder. This is how you run a build inside a sandbox, a container shim, a
nice/taskset prefix, or any other launcher.
[wrap] takes a single command, written either as a shell string or as an argv
array. Exactly one {{command}} placeholder is required:
# String form — {{command}} appears once in the shell string
[]
= "firejail --quiet -- {{command}}"
# Array form — {{command}} must be a complete argument (never argv[0])
[]
= ["nice", "-n", "10", "sh", "-euc", "{{command}}"]
At run time pekit assembles the export prelude plus your target command into a script, and:
- String wrapper: the script is shell-quoted and spliced in at
{{command}}, then executed withsh -euc. - Array wrapper:
{{command}}is replaced by the script; the wrapper's first element is the program that is executed.
Only one wrapper is active per run. Like [env], [wrap] may be declared in
several places, and the last non-empty one wins, in this order (lowest to
highest precedence):
- workspace
[wrap] - delegated source env file
[wrap](only when the recipe delegates wrap) - recipe
[wrap] - the selected env file's
[wrap]
So a recipe wrapper overrides the workspace's, and the selected env file's wrapper overrides the recipe's.
dependency_provider #
Env files may also set dependency_provider, a single string naming which of a
target's declared [build.<target>.dependencies.<provider>] blocks is exported
to the build environment (PEKIT_DEPENDENCIES, PEKIT_DEPENDENCY_PROVIDER,
PEKIT_DEPENDENCIES_FILE). It is not an environment variable; it selects a
declared provider block by name. When both a delegated source env file and the
recipe's own env file set it, the recipe's env file wins. See
Dependencies and claims for what the
selection exports.
Keyrings #
Keyrings supply secrets and keys to targets. Every keyring value is exported to the
target as a PEKIT_KEYRING_* environment variable. There are two ways to provide
them: keyring files and inline literals.
--keyring=<value> (keyring files) #
--keyring names a keyring file and is repeatable. The value is resolved like
this:
- If the value is path-like, it is treated as a path immediately. A value is
path-like when it is absolute, begins with
., begins with~, or contains a/. The path is resolved relative to the current directory; if the file does not exist, that is amissing_keyringerror. - Otherwise the value is treated as a name: pekit looks for
<value>.keyring.pekit.tomlin each search root in turn. For a plain recipe the search root is the recipe directory; inside a workspace the workspace root is searched first, then the recipe directory. - If no named file is found in any search root, pekit falls back to treating the
value as a path relative to the current directory; if that does not exist
either, it is a
missing_keyringerror.
In a pekit workspace run, named keyrings are resolved once, against the
workspace root, and the resulting values are shared with every member.
# Resolved by name → looks for prod.keyring.pekit.toml in the search roots
# Path-like → loaded directly
When several --keyring files are given, they are loaded in command-line order and
each overlays the previous — so a later keyring file overrides an earlier one on
the same key.
--keyring.<dotted.path>=<value> (literals) #
To inject a single value without a file, use --keyring.<dotted.path>=<value>. The
dotted path is sanitised into an exported variable name — upper-cased, prefixed
with PEKIT_KEYRING_; the exact sanitisation rules are in
Supporting files.
# exported to the target as PEKIT_KEYRING_TCB_PRIV
Literals are keyed by their dotted path, so repeating the same path replaces the earlier value with the later one.
Precedence #
Keyring sources combine in this order, lowest to highest precedence:
earlier --keyring file < later --keyring file < --keyring.x.y= literal
That is: keyring files overlay in command-line order, and all inline literals are
applied last, so a --keyring.x.y= value always overrides whatever a keyring file
provided for the same exported name. Any resulting PEKIT_KEYRING_* name that
would collide with a managed PEKIT_* variable or with a user [env] variable is
an env_collision error.
Package signing #
One keyring entry is read by pekit itself rather than merely exported:
signing.package_key names the Ed25519 private key that signs every
peipkg-format artifact a package or publish run produces. Because it rides
the keyring mechanism, the key path stays in a per-developer, gitignored file —
or arrives inline as --keyring.signing.package_key=<path> — and private key
material never enters a recipe. Without a key, publish refuses peipkg
packages unless you pass --allow-unsigned. The exact behaviour and the
accepted key encodings are in
Supporting files.
Binary signing #
A build target's sign table
also reads the keyring, but through entries the recipe names rather than a
fixed one: "bin/peinit" = "tcb.priv" signs that output file with the key
whose path the tcb.priv leaf holds. Any leaf will do; the convention in
Peios' own recipes is [tcb] priv = "<path>". See
Signing binaries for PIP.
Keyring file format #
A *.keyring.pekit.toml file is a TOML document whose leaves are strings. Each
string leaf becomes one PEKIT_KEYRING_* variable, named from its full dotted key
path using the same sanitisation as inline literals. Nested tables are flattened:
# prod.keyring.pekit.toml
= "<value>" # -> PEKIT_KEYRING_TOKEN
[]
= "<value>" # -> PEKIT_KEYRING_TCB_PRIV
= "<value>" # -> PEKIT_KEYRING_TCB_PUB
A leaf that is not a string is rejected, and typed entries — a sub-table carrying a
path or content key — are reserved and currently rejected as
unsupported_keyring_entry. The full schema is documented in
Supporting files.
A note on secrets in output #
Keyring values are exported to the target like any other environment variable.
pekit does not redact them from a target's stdout/stderr — if your command
prints a secret, pekit streams it verbatim. pekit's own diagnostics and its
--verbose environment summary log variable names only, never values, but you
are responsible for not echoing secrets from inside your commands. Prefer writing
secrets to files or consuming them directly rather than printing them.
Where to go next #
For the managed PEKIT_* variables these layers sit on top of, read the
environment contract in the command-line reference.
For the exact env-file and keyring schemas, read Supporting files.
For the signing key that rides the keyring mechanism, read Signing and provenance.