Running pekit
Single-page view · as markdown
Commands and targets
pekit / Running pekit
Every pekit invocation names exactly one command. The command decides three things up front, before any side effect: which flags are legal, how many versions the run may cover, and which targets in the recipe are selected and in what order. This page is the reference for that surface.
For the flags themselves (how they parse, global flags like --dry-run), see
Invocation and flags. For version selectors
(--version, --latest, --all-versions), see Versions.
The commands #
| Command | Selects | Versions | --all | Side effects |
|---|---|---|---|---|
build | build targets + their needs | multiple | no | Runs build targets; stages their output under out_dir. |
test | test targets + the builds they need | single resolved | no | Stages needed builds, then runs the selected test targets. |
install | install targets + the builds they need | single resolved | no | Stages needed builds, then runs the selected install targets. |
package | package members + the builds they list | multiple | yes | Stages builds and writes .peipkg artifacts under out_dir. Recipes with a reproducible source also emit a corresponding-source package. |
publish | package members (as package) | multiple | yes | Packages, then publishes to a configured localdir destination. |
clean | one optional clean target | none | no | Runs a clean target and/or removes the managed output directory. |
gen | gen targets | none | yes | Runs gen commands; writes generated source into the tree. |
verify | gen targets | none | yes | Runs gen verify_commands; a read-only drift check (writes nothing). |
lock | the recipe's source | multiple | no | Fetches and pins the selected versions in pekit.lock without building. With no version flags, reports the current lock instead. |
workspace | a delegated command across members | (delegated) | (delegated) | Runs one of the above across every workspace member. |
gen and verify are the source-generating pair — see
Generating source below. The rest of this
page's target model applies to build/test/install/clean; gen targets
have their own shape.
workspace is a wrapper: it parses a delegated command after its own flags and
runs it for each member. Its capabilities are exactly the delegated command's.
See Workspaces.
Version behaviour #
build, package, publish, and lock iterate over every version the
selector resolves to, running the whole plan once per version. test and install
require the selector to resolve to a single version; if more than one
resolves, pekit stops before doing any work:
invalid_version_selector: test requires a single resolved version
clean, gen, and verify never resolve versions or materialise a source
tree — they operate on the recipe directly (the output directory, or the
committed tree at the recipe root), so version-selection flags are not accepted
(see the capability matrix below).
Command / capability matrix #
Each command accepts a fixed set of flag groups. Passing a flag the command does
not support is an error up front (unsupported_flag), unless you pass
--allow-unused, which downgrades it to a suppressed warning. Broadly:
build, test, install, package, and publish share the version-selection,
local-source, --no-build, --no-verify, and --refresh-source groups; --all
is package/publish/gen/verify only; --allow-unanchored and --allow-unsigned are publish
only; clean takes only --env, --keyring, and its own mode flags
(--output-only / --target-only); gen and verify take only --env,
--keyring, and --all; lock takes version selection, --refresh-source,
and its own --repin. The full command-by-flag matrix is in the
command-line reference.
Global flags (--dry-run, --quiet, --verbose, --json, --recipe,
--allow-unused) are accepted by every command and are covered in the
invocation reference.
The target model #
build, test, install, and clean each read a same-named section from the
recipe. A section holds one or more targets, and a target is a shell command
plus optional metadata. (gen targets live in a [gen.*] section too, but have
a different shape — see below.)
Bare (main) targets #
A bare section — with a command key directly on it — defines a single target
named main:
[]
= "make -j$(nproc)"
[]
= "make check"
pekit build with no selector runs build.main; pekit test runs test.main.
The keys allowed directly on a bare section are command, needs, clear_out,
and (for [build] only) dependencies.
Named targets #
Give a section sub-tables to define named targets, [<command>.<name>]:
[]
= "make lib"
[]
= "make tools"
= ["lib"]
A target name must be a canonical selector: one or more of the characters
A-Z a-z 0-9 _ . + -, and it may not start with - or contain / or :. An
invalid name is rejected at load time (invalid_selector).
Bare and named cannot mix #
Within one section you use either the bare form or named sub-tables,
never both. Putting a command key on the section and also adding a sub-table
that is not a recognised target key is an error:
mixed_targets: cannot mix bare [build] target with named [build.foo]
Target selection #
When you name selectors (positional arguments, or arguments after --), pekit
selects exactly those targets; each must exist in the section or the run fails:
missing_target: unknown build target "libz"; available targets: lib, tools
With no selector, selection follows these rules for the command's section:
| Situation | Result |
|---|---|
Section has a main target | main is selected. |
No main, command is build, exactly one target exists | that lone target is selected. |
No main, and none of the above | error, listing the available targets. |
| Section is absent / empty | missing_target: recipe has no <command> targets. |
The single-target fallback is build-only. For test and install, a bare
pekit test with no test.main and multiple test targets is ambiguous:
ambiguous_target: no test.main target; available targets: fast, full
Build dependencies (needs) #
A target's needs lists build target names that must be staged first.
- For
build, the selected targets and everything reachable throughneedsare gathered and run in dependency order (a target runs after everything it needs). Each build runs at most once per invocation. - For
test,install, andpackage, pekit pulls the build targets named in the selected target'sneeds(forpackage, thebuildslist on the package member), stages those builds first in dependency order, then runs the selected test/install targets (or writes packages).
A needs entry that does not name an existing build target is an error:
missing_target: test.full needs missing build target "core"
Dependency edges form a DAG. A cycle is detected and reported with the path:
target_cycle: build dependency cycle: a -> b -> a
--no-build may name already-staged build targets to skip re-running them;
naming a build target that does not exist is likewise a missing_target error.
clean #
clean has two independent effects, gated by two mutually exclusive mode flags:
| Invocation | Runs clean target | Removes output dir |
|---|---|---|
pekit clean (default) | yes, if clean.main (or the named selector) exists | yes |
pekit clean --output-only | no | yes |
pekit clean --target-only | yes | no |
Details:
- Default. Runs
clean.mainif present (if there is noclean.mainand no selector, the target step is simply skipped — not an error), then removes the recipe's managed output directory (out_dir). --output-only. Removes the output directory only; no target runs. Because no target runs,--envand--keyringare unused and rejected (unsupported_flag), unless--allow-unuseddowngrades them to warnings.--target-only. Runs the clean target only; the output directory is left in place. With noclean.mainand no explicit selector this is an error:missing_target: --target-only requires clean.main or an explicit clean target.
clean accepts at most one target selector; more than one is rejected:
invalid_selector: clean accepts at most one target selector
--output-only and --target-only cannot be combined.
--all for package and publish #
A multi-package recipe has several package members. package and publish
select members the same way targets are selected — by naming them, with an
optional :instance suffix for multi-package
enum instances — with two conveniences:
- With no selector and a single member, that member is used; with no selector and multiple members, the run is ambiguous and lists the members.
--allselects every member.
--all is exclusive with explicit selectors:
invalid_flags: --all cannot be combined with package selectors
On any command other than package or publish, --all is an unsupported flag.
Generating source (gen and verify) #
Some recipes have source that is generated and committed to the tree —
language bindings from a canonical header, a table baked from a schema, a file
derived from another. That is not a build: a build reads source and writes
artifacts under out_dir; a generator reads canonical inputs and writes
generated source back into the tree. pekit models it as a separate command,
gen, with a matching drift check, verify.
A gen target runs at the recipe root and its product is the files it writes
in-tree. Its $PEKIT_OUT is a pekit-managed scratch directory
(<out_dir>/.scratch/gen/<name>, cleaned on success, kept on failure) that sits
outside the artifact area — packaging and --no-build reuse never see it. It is
a handy place for a verify_command to regenerate into.
[]
# Runs at the recipe root; writes generated source in place.
= "./tools/gen-bindings.sh"
# Optional drift gate. Exit 0 = in sync, non-zero = stale. It owns its own
# comparison — pekit never diffs for you and never assumes the generator is
# deterministic. A common shape regenerates into the scratch $PEKIT_OUT and
# diffs against the committed tree:
= """
./tools/gen-bindings.sh --out "$PEKIT_OUT/fresh"
diff -ru generated "$PEKIT_OUT/fresh"
"""
# Scope of the pre-flight (see below). Omit to gate every build/test.
= ["thing-that-embeds-bindings"]
= []
[]
= "*"
The keys allowed on a [gen.<name>] target are command (required),
verify_command, verify_on_build, verify_on_test, dependencies, and
verify_dependencies. Gen targets have no needs and no build DAG — they
are independent of the build chain. Bare [gen] means gen.main, exactly like
the other sections; pekit gen --all / pekit verify --all operate on every
gen target.
pekit gen <name>runscommand— regenerate in place.pekit verify <name>runsverify_command— check only, never writes the tree. This is the on-demand / CI drift gate. Naming a gen target that has noverify_commandis an error. A barepekit verifyselectsgen.main, exactly like the other commands;pekit verify --allruns every gen target that has averify_command, and errors only when none does.
Pre-flight drift checking #
The point of verify_command is that a consuming command never proceeds from a
stale tree by accident. Before build, test, install, package, or
publish runs, pekit runs the drift gates scoped to what that command will
do, and fails fast if any is stale:
gen_out_of_date: gen target "bindings" is out of date: ...
fix: pekit gen bindings
skip: pekit build thing --no-verify=bindings
Scope is per namespace, and the two keys are separate because a [build.x] and
a [test.x] can share the bare name x:
verify_on_build / verify_on_test | Meaning |
|---|---|
| absent | Gate every target in that namespace (the safe default). |
[] (empty) | Gate nothing — the drift check runs only via pekit verify. |
["a", "b"] | Gate only when target a or b in that namespace runs. |
A gen target's gate fires when its verify_on_build gates a build the command
will run, or its verify_on_test gates a test it will run. package and
publish gate conservatively against every build target (they run builds under
the hood, and the exact set depends on package resolution). Multiple stale gates
are collected and reported together, not one at a time.
Set verify_on_build = [] for a generator whose output nothing builds (a
downstream SDK, say): leaving it absent would drag the generator's toolchain
into every unrelated build's pre-flight for no benefit. Use the scoped list
form when only some targets actually consume the generated output.
--no-verify and verify_dependencies #
--no-verify opts out of the pre-flight for one invocation:
pekit build --no-verifyskips all drift gates.pekit build --no-verify=bindings,otherskips only the named gens (an unknown name is an error).- On
package/publish,--no-verifyskips the same pre-flight — those commands gate conservatively against every build target, as above.
By default verify_command runs with the gen target's dependencies. If it
needs a different (usually smaller) toolchain — a cheap hash check rather than
a full regen — declare [gen.<name>.verify_dependencies.*]; when present it
fully replaces dependencies for the verify run.
Selector rules #
Selectors are the command's positional arguments plus everything after a --
separator. Two rules apply to all of them:
-
A selector may not start with
-. A leading dash makes pekit treat the value as a (probably mistyped) flag; put it after--instead:invalid_selector: selector "-lib" starts with '-'; use -- before selector-like values -
Target selectors must be canonical (
A-Z a-z 0-9 _ . + -, no leading-, no/or:). Package selectors follow the same grammar but allow a single:to separate a member from an enum instance.
See also #
- Invocation and flags — the grammar, flag value forms, and global flags.
- Command-line reference — the full command-by-flag capability matrix.
- Versions — the version selectors these commands accept.
- Workspaces — running a command across every member.
Invocation and flags
pekit / Running pekit
This page is the reference for pekit's command line: the grammar, how flag values are written, the flags every command accepts, and how pekit decides which recipe to run. For what each command does and how it selects targets, see Commands and targets.
Grammar #
pekit [global flags] <command> [args and flags]
pekit [global flags] workspace [workspace flags] <command> [args and flags]
There is exactly one command per invocation, drawn from build, test,
install, package, publish, clean, gen, verify, lock, and
workspace. Anything before the
command that is not a recognised flag is an error (unknown_command /
unknown_flag); a missing command is missing_command.
Flags may appear before or after the command. Pekit parses flags the same way on both sides of the command word, so these are equivalent:
$ pekit --dry-run build
$ pekit build --dry-run
The one structural exception is workspace, whose tail is parsed specially: the
workspace-only flags (--fail-fast, --jobs) must come before the delegated
command, and everything after the delegated command is parsed as an ordinary
invocation of that command. See Workspaces.
-- stops flag parsing #
A bare -- ends flag parsing. Every token after it is captured verbatim and
treated as a positional selector:
$ pekit build -- ./tools ./libs
Positional selectors may not begin with -. A leading-- token before -- is
read as a flag (and rejected as unknown if it is not one); after -- it is still
rejected as a selector with the diagnostic "use -- before selector-like
values". Use -- for selectors that merely look option-like (for example a
path), not to pass a selector that begins with a dash.
Flag value forms #
Flags fall into three shapes — no-value, required-value, and optional-value — and the shape decides how a value may be written. Which shape each flag takes is tabulated per flag in the command-line reference; the rules are as follows.
No-value flags (--dry-run, --json, and the like) never take a value:
--flag=x is an error (unexpected_flag_value).
Required-value flags accept either the separated form (--recipe path) or the
= form (--recipe=path). With the separated form pekit consumes the next token
as the value — unless that token is missing, is --, or itself begins with -,
in which case the flag errors with missing_flag_value. With the = form an
empty value (--recipe=) is rejected the same way.
Optional-value flags take a value only through =. They never consume the
following token. So:
$ pekit build --local # --local with an empty value
$ pekit build --local=/src/mypkg # --local with a value
$ pekit build --local /src/mypkg # --local (empty) + /src/mypkg as a selector
-V is an alias for --version and behaves identically (it is a required-value
flag; it is not a version-print flag — pekit has no version banner and no
--help).
Per-key keyring values use a dotted-path form, --keyring.<path>=<value>, which
always requires the = and a non-empty path.
Global flags #
Seven flags are parsed in any position regardless of command: --recipe,
--workspace, --allow-unused, --dry-run, --quiet, --verbose, and
--json. Six are accepted by every command; the seventh, --workspace, is only
valid with the workspace command. Each is covered in its own section below,
and the command-line reference tabulates all seven with
their value forms. The rest of pekit's flags (--version/-V, --latest,
--local, --env, --keyring, --all, and so on) are command-specific and
are covered on Commands and targets.
--recipe <path|dir> selects the recipe directly instead of searching upward
from the cwd (see How a recipe is located);
--workspace <path|dir> does the same for the workspace file.
Mutual-exclusion rules #
Validation rejects contradictory combinations up front, before any work runs:
--quiet cannot be combined with --verbose or --json; --recipe cannot be
combined with --workspace (and --workspace is rejected outside the
workspace command); the version selectors (--version / --latest /
--all-versions), the local-source flags (--local / --prefer-local), and
the clean modes (--output-only / --target-only) are each mutually exclusive
within their group. The complete validation table, with each diagnostic, is in
the command-line reference.
--allow-unused #
Every command declares which flags it accepts. Passing a recognised flag that
the effective command does not support is normally an error
(unsupported_flag), for example version selection on clean, or --all on
build. --allow-unused turns each such case into a non-fatal notice: pekit
records it and emits an unused_suppressed event at the start of the run instead
of aborting.
--allow-unused only relaxes the "recognised-but-unused" check. It does not
excuse:
- unknown flags (
unknown_flag) or unknown commands (unknown_command); - malformed flag values — missing values, empty
=values, or=valueon a no-value flag; - the mutual-exclusion rules above;
- bad selectors (a selector beginning with
-, or more selectors than the command accepts).
This is what lets you fan a shared set of flags across a whole workspace even when some members' commands ignore some of the flags.
--dry-run #
--dry-run runs the same planning pipeline as a real invocation — it locates and
loads the recipe, resolves the version selector, and prepares source state — but
stops at every side-effecting boundary instead of crossing it. In place of the
action pekit emits a plan event:
- a target that would run emits
target_plan("would run target") and does not execute the command or touch the stage directory; cleanthat would delete managed output emitsclean_plan("would remove managed output") and removes nothing;package/publishemit their plan events rather than writing or shipping artifacts.
Use --dry-run to inspect exactly what a command would do. Combine it with
--json to get the plan as a single structured object (below).
Output renderers #
The renderer is chosen from the flags. --json selects the JSON renderer;
otherwise the human renderer is used, and --quiet puts it in quiet mode.
Human (default) #
Pekit's own events print as timestamped lines, [15:04:05] message, optionally
prefixed with a context tag (member, command, target, package, version) when the
event carries one. Live output from the targets pekit runs is streamed through
with a [member target version] prefix, preserving each line's original stream —
stdout stays on stdout, stderr stays on stderr. Fatal errors are written to
stderr.
--quiet #
Quiet mode keeps the human format but drops routine progress, emitting only
warning, artifact, publish, workspace_summary, lock, and sign
events. Target output
is unaffected. --quiet cannot be combined with --verbose or --json.
--verbose #
Verbose mode adds extra events (such as per-target stage preparation) to the human log. It is otherwise the human renderer.
--json #
--json emits newline-delimited JSON — one event object per line — to stdout, so
the run can be consumed by another program. Each object has a type and a
timestamp plus whatever context fields apply; target output arrives as
target_output events carrying the stream and text. Errors are emitted as an
error event.
--dry-run --json is special-cased: instead of streaming events, pekit buffers
them and, at the end, prints a single plan object:
The events array holds the buffered plan events; command is the effective
command and workspace is whether the run went through the workspace command.
Exit codes #
Pekit uses two exit codes only: 0 on success, 1 on any error — bad
invocation, missing recipe, failed build, and so on. There are no finer-grained
codes, and there is no --help or help command: this page,
Commands and targets, and the
command-line reference are the manual.
How a recipe is located #
Pekit resolves exactly one recipe per (non-workspace) run:
--recipe <path|dir>— if given, this wins. A directory means<dir>/pekit.toml; a file path is used as-is. A missing target ismissing_recipe.- A remote positional — if
--recipewas not given and the first positional argument looks like a remote locator, it is taken as the recipe (and dropped from the selector list). - Walking up — otherwise pekit searches from the current directory upward
for the nearest
pekit.toml, stopping at the filesystem root (not_foundif none is found).
Once the recipe root is known, and unless --workspace was supplied, pekit also
walks upward from that root for a workspace.pekit.toml; if one is found the
recipe is treated as a member of that workspace.
Remote recipe locators #
A remote locator is a recipe reference that points at a git repository rather
than a local path. A value is treated as remote when it does not start with .
or / and it either begins with github.com/, contains ://, ends with .git,
or contains a // subdirectory separator. Examples:
$ pekit build github.com/acme/tools
$ pekit build github.com/acme/tools//subdir@v1.2.0
$ pekit build https://git.example.org/acme/tools.git//pkg@main
The locator may carry a //subdir to select a directory inside the repository and
an @ref (branch, tag, or commit) to pin a revision. When pekit sees a remote
locator — whether as the positional or as the value of --recipe — it
materialises it: it mirror-clones the repository into a cache under the
system temp directory (fetching updates if already cached), checks out the
resolved commit into a clean working tree, and then treats the checkout (plus any
//subdir) as the recipe root. From there the run proceeds exactly as it would
for a local recipe.
See also #
- Commands and targets — what each command does and selects.
- Command-line reference — every flag, its value form, and the validation table.
- Workspaces — the workspace tail and its flag placement.
- Sources — what materialising a remote recipe's source involves.
Workspaces
pekit / Running pekit
A workspace lets you drive a whole collection of recipes as one unit. Instead
of running pekit build in each recipe directory by hand, you declare which
recipes belong together in a workspace.pekit.toml file and run a single
pekit workspace build, which delegates that command to every member. This is
how a distro-scale tree of packages — dozens or hundreds of recipes — is built,
tested, packaged, and published in one go.
A workspace does three things:
- Discovers members from
include/excludeglobs (each member is a directory containing apekit.toml). - Shares configuration —
[env],[wrap], and a distro-wide[policy]— down to every member. - Schedules and delegates a single command across all members, with optional concurrency, fail-fast, and cross-member safety checks.
The workspace.pekit.toml file #
The workspace file is TOML and lives at the root of the tree it governs. Its
schema is small — exactly five top-level keys are accepted, and anything else
is rejected with unknown_key: include and exclude
(globs selecting and removing member directories, relative to the workspace
root), [env] and [wrap] (shared configuration contributed to every member),
and [policy] (distro-wide derivation policy — see
Policy). The key-by-key schema is in
Supporting files.
include is mandatory: an omitted or empty include fails with missing_key
("workspace include is required"). Everything else is optional.
# workspace.pekit.toml at the distro root
= ["core/*", "extra/**"]
= ["extra/experimental/*"]
[]
= "1700000000"
[]
= ["nice", "-n", "10", "{{command}}"]
[]
= "GLIBC_"
Member discovery #
include and exclude are glob patterns matched against the directory tree
under the workspace root. Matching uses doublestar globbing, so ordinary
wildcards (*, ?, character classes) work and ** matches across directory
boundaries. Patterns are always workspace-root-relative.
Discovery proceeds as:
- Every
excludepattern is expanded and its matches recorded. - Every
includepattern is expanded. A match is kept only if it was not excluded and the matched directory contains apekit.toml.
A matched directory with no pekit.toml is silently ignored — it is simply not a
recipe, so it is not a member. This means you can point include at a broad glob
(extra/**) and only the directories that actually hold recipes become members.
If discovery yields no members at all, the run fails with empty_workspace
("workspace has no members").
Locating the workspace #
When you run pekit workspace, pekit finds the workspace file by walking upward
from the current directory looking for workspace.pekit.toml (the same
upward search recipes use). You can override this with --workspace <path>,
which accepts a path to a workspace.pekit.toml file, a directory containing
one, or a remote locator (for example a github.com/... reference).
Member identity #
Each member has a stable id: its workspace-relative directory path, written
with forward slashes (for example core/zlib or extra/tools/ripgrep). This id
is the member's name everywhere it appears:
- in diagnostics and error messages (
member_failed,member_skipped); - in structured JSON events and in output-line prefixes;
- in the publish-collision owner labels (see below);
- in the end-of-run summary.
Members are processed in a deterministic order: ids are sorted lexicographically. With the default single-worker execution this is the exact order members run in; with concurrency it is the order in which work is dispatched.
Invocation #
pekit [global flags] workspace [workspace flags] <command> [args and flags]
workspace is itself a command, but it does no building of its own — it
delegates another command — any of build, test, install, package,
publish, clean, gen, verify, or lock — to every member. That delegated command and its arguments
come after the workspace flags.
Strict flag placement #
The tail after workspace is parsed specially, and the
placement rules are strict:
- Workspace flags —
--jobs Nand--fail-fast— must appear afterworkspaceand before the delegated command. They are recognised only in this slot. - Command-level flags (version selection,
--local,--env,--keyring,--refresh-source,--all, …) belong to the delegated command and must come after it. Placing one before the delegated command is an error with a hint: "workspace command flag<name>must appear after the delegated command." - Global flags (
--dry-run,--quiet,--verbose,--json,--allow-unused,--workspace) may appear either beforeworkspaceor in the tail before the delegated command.
# correct: workspace flags before the command, command flags after
$ pekit workspace --jobs 4 --fail-fast build --version 1.2.3
# also correct: a global flag may precede workspace
$ pekit --dry-run workspace build
# error: --version is a command flag placed before the delegated command
$ pekit workspace --version 1.2.3 build
# workspace command flag version selection must appear after the delegated command
Because --jobs/--fail-fast are recognised only in the workspace slot,
writing them after the delegated command hands them to the delegated command's
own parser, which rejects them as an unknown_flag. Keep them in the workspace
slot.
Both failures share the diagnostic code missing_workspace_command: it fires
when a command flag precedes the delegated command, and when no delegated
command follows the workspace flags at all ("workspace requires a delegated
command").
Execution model #
Each member is run as an ordinary per-recipe invocation of the delegated
command — exactly as if you had cd'd into that member and run
pekit <command> ... there, with the delegated flags and any positional
selectors carried through unchanged.
Concurrency: --jobs #
--jobs N sets how many members run concurrently. N must be a positive
integer; a missing value is missing_flag_value and a non-positive or
non-numeric value is invalid_flag_value ("--jobs requires a positive
integer"). The default is 1, i.e. members run one at a time in id order.
Fail-fast: --fail-fast #
By default the workspace attempts every member even if some fail: it runs
them all and then fails the overall command at the end if any member failed
(workspace_failed, "N workspace member(s) failed").
With --fail-fast, the first member failure sets a stop flag so that no
further members are started. Members that had not yet begun are recorded as
skipped.
No inter-member dependencies #
Members are independent. Pekit does not compute a dependency graph between members, does not topologically order them, and does not make one member's build visible to another as an input. The only ordering is the lexicographic id order used for dispatch. If a member needs another member's output, that relationship must be expressed through the normal recipe machinery (sources, package dependencies), not through workspace membership.
Summary #
At the end of a run pekit emits a workspace_summary event tallying members as
succeeded, failed, or skipped:
12 succeeded, 1 failed, 3 skipped
Succeeded and failed count members that actually ran; skipped covers
members not started because of --fail-fast, and members skipped by selector
planning (below). The overall command exits non-zero if any member failed.
Per-member strictness #
The delegated command and its flags are validated once, when the tail is parsed.
Beyond that, strictness is evaluated per member, because members differ: one
recipe may define a build target or a package selector that another does not.
-
Each member runs the delegated command against its own recipe. If a flag or selector cannot be honoured by that member's recipe, that member errors — its failure is reported (
member_failed) and counted, and the other members are unaffected (unless--fail-fastis set). -
--allow-unusedrelaxes this per member. When a run carries positional selectors (target names, ordef:variantpackage selectors) together with--allow-unused, pekit plans which selectors apply to which member:- selectors that a member cannot satisfy are suppressed for that member
(reported via
workspace_selector_suppressed); - a member to which none of the requested selectors apply is skipped ("no requested selectors apply to member");
- if a requested selector applies to no member at all, the whole run
fails with
missing_selector("workspace selector … matches no selected member").
--allow-unusedalso suppresses per-member unsupported version selection rather than failing that member. - selectors that a member cannot satisfy are suppressed for that member
(reported via
Without --allow-unused, selectors are passed to every member verbatim and a
member that does not recognise one fails in the usual way.
Cross-member publish collision detection #
When the delegated command is publish, two members could be configured to
publish an artifact to the same destination — a mistake that would have them
overwrite each other. Pekit catches this before publishing anything.
A preflight pass runs each member's publish planning in dry-run mode and
reserves every resolved destination path in a shared registry. Destinations
are keyed by path, each with an owner of the form <member-id>:<instance>. Reserving a path that a different owner already holds
fails the entire run with publish_collision ("publish destination collision
between … and …"). Reserving the same path for the same owner is idempotent, so a
member does not collide with itself.
Because this is a preflight, a destination clash is reported up front, before any member has written to a publish target.
Shared configuration #
[env] and [wrap] #
[env] and [wrap] declared at the workspace level are shared defaults
contributed to every member, layered together with each member recipe's own
[env] and [wrap]. For how these compose with recipe- and environment-file
values — and the precedence between them — see
Environments and keyrings. The
[wrap] command follows the same {{command}} placeholder rules as a recipe
wrap. Keyrings behave similarly: a named --keyring on a workspace run
resolves against the workspace root once, and the values are shared with
every member.
Policy: symbol-version floors #
The [policy] table carries distro-wide derivation policy. Its one current
sub-table is [policy.symbol_versions], a map from a shared-library soname
to the symbol-version token prefix whose tokens are commensurable with the
providing package's version:
[]
= "GLIBC_"
= "GLIBCXX_"
This governs which sonames receive a symbol-version floor when pekit derives a
package's dependencies across the workspace — so that, for example, a binary
using a GLIBC_2.34 symbol gains an appropriate lower bound on its libc
dependency. An unknown sub-table under [policy] is rejected
(unknown_key, "policy.… is not a known policy table"). For how derived
dependencies and claims work, see
Dependencies and claims.
Where to go next #
For the recipes a workspace's members are, read Anatomy of a recipe.
For how workspace [env] and [wrap] compose with a recipe's own, read Environments and keyrings.
For the full flag surface and remote locators, read Invocation and flags.
Signing and provenance
pekit / Running pekit
A published package is a claim: these bytes were built from these inputs, by
this recipe, with this tool. Pekit backs that claim end to end. On the input
side, fetched sources are pinned trust-on-first-use in
pekit.lock and upstream release
signatures can be verified against committed keys — both covered on the
Sources page. This page covers the output side:
signing the artifacts pekit writes, the provenance recorded in every
manifest, the corresponding-source package that keeps a build's exact inputs
publishable, and the checks publish runs before shipping anything.
Signing artifacts #
Package signing is configured through one well-known keyring entry,
signing.package_key, whose value is the path to an Ed25519 private
key — either the raw 32-byte seed or a PKCS#8 PEM as produced by
openssl genpkey -algorithm ed25519. A relative path resolves against the
invocation's working directory. Because it rides the
keyring mechanism, the key path
lives in a per-developer, gitignored file — or arrives inline as
--keyring.signing.package_key=<path> — and private key material never
enters a recipe.
$ pekit publish --keyring=dev
With a key configured, every peipkg-format artifact the run produces —
package members and the corresponding-source package alike — is packed with
an embedded signature naming the key's fingerprint (the lowercase hex SHA-256
of the raw 32-byte public key), and each emits a sign event. Sign events
stay visible under --quiet, so a quiet run still shows what was signed with
which key.
Two failure behaviours are deliberate:
- A configured key that cannot be loaded is a hard
signing_keyerror. Pekit never falls back to silently writing unsigned output — a signing setup that degrades quietly would defeat the point. - Without a key,
packagewrites unsigned artifacts (fine for local development), butpublishrefuses peipkg-format packages withunsigned_publishunless you pass--allow-unsigned.
Signing binaries for PIP #
Package signing protects the artifact in transit; it says nothing to the kernel about the programs inside. The Peios kernel derives a process's Process Integrity Protection tier from a second, separate signature carried by the binary itself, and pekit produces that signature too — as a step of the build target, not of packaging.
A build target lists the files to sign in a sign table, keyed by signature
kind. The pip kind maps paths or globs relative to the target's $PEKIT_OUT
to the keyring entry that holds the signing key:
[]
= "make && make install DESTDIR=$PEKIT_OUT"
[]
= "tcb.priv"
= "tcb.priv"
# dev.keyring.pekit.toml — per-developer, gitignored
[]
= "kacs-tcb-dev.key" # openssl genpkey -algorithm ML-DSA-65
The value names any keyring leaf; there is no fixed entry name. It holds the
path to an ML-DSA-65 private key — PKCS#8 PEM, or a raw 32-byte seed —
resolved against the invocation's working directory when relative. The kind
decides only the format and the storage location: for pip that is the
fixed 3310-byte blob in an
ELF section named .peios.sig.
Signing runs after the target's command exits 0 and before any dependent
target or package sees the output, so it is the last thing to touch the
binary. That ordering is why this is a build step: the signature covers the
whole file, so every strip, debug split or patchelf a recipe performs has to
come first, and those all live in build commands. It also means pekit test
exercises the signed binary rather than an unsigned stand-in.
For each matched file, pekit:
- Adds a zero-filled
.peios.sigsection, or reuses one the build already reserved (it must beSHT_PROGBITSand exactly 3310 bytes). Adding a section appends to the file and never moves existing bytes, so program headers and loadable segments are unaffected. - Hashes the file with the section contents zeroed, signs the hash with pure ML-DSA-65 (deterministic, empty context) and writes the blob into the reserved bytes.
- Verifies the finished file the way the kernel will before returning, and
emits a
signevent naming the keyring entry and the key's fingerprint — the SHA-256 of the raw public key, the same bytes a kernel key table carries.
Two properties are deliberate:
- Every failure is fatal. A keyring entry that is missing or does not
load, a pattern that matches nothing, a file that is not a 64-bit
little-endian ELF, a reserved section of the wrong size, and two patterns
naming different keys for one file all abort the target. There is no
--allow-unsignedequivalent, because an unsigned binary is not a weaker binary: it runs with no tier and no diagnostic, and build time is the only place the mistake is visible. - Only the ELF section form is produced. The specification also allows the signature in an extended attribute, but package payloads carry no extended attributes, so nothing pekit ships could use it. Scripts and other non-ELF files take their interpreter's tier and cannot be usefully signed.
Which tier a signature confers is a property of the key, not of anything in
the recipe: the kernel looks the verifying key up in its compiled-in table.
Getting a key into that table is a kernel-build question — see the tcb.pub
entry the pkm recipe consumes — and a binary signed with a key the kernel does
not carry is simply unsigned there.
What the manifest records #
Every peipkg manifest pekit writes carries a build block stating where the
artifact came from:
| Field | Value |
|---|---|
timestamp | The run's start time, UTC RFC 3339. |
farm_id | local — pekit builds are local builds. |
source_ref | The source provenance ref: git:<url>@<commit>, url:<url>#sha256:<hash>, and so on. |
recipe_ref | The recipe tree's own git commit, git:<commit>. Suffixed +dirty when the work tree has uncommitted changes anywhere — including a freshly written, not-yet-committed pekit.lock. Omitted when the recipe is not inside a git work tree. |
builder | The producing pekit's own revision: pekit/<12-hex-commit>, +dirty when built from a modified tree, falling back to the module version when the binary carries no VCS stamp. |
source_package | The name of the corresponding-source package emitted from this recipe (below); empty when none is. |
Together these state the full provenance chain — the exact upstream inputs,
the exact recipe that drove the build, and the exact tool that performed
it. recipe_ref deliberately resolves the enclosing repository: a
workspace member's identity is the workspace repository's commit, not some
per-recipe notion. A dirty tree is over-marked rather than under-marked,
because a commit id alone does not describe a build whose tree had local
changes.
Corresponding-source packages #
A recipe with a reproducible source automatically emits a
corresponding-source package whenever it packages peipkg-format
members. This is how the distribution meets copyleft source-availability
obligations: publishing a binary and its source package through the same
channel keeps the exact inputs of every published build available for as long
as the binary is.
One source package covers every peipkg member the recipe produces. It is emitted when all of these hold:
source_package.enabledis not set tofalse(emission is the default);- the source actually materialised as git with a resolved commit or url — a local override never qualifies, and neither does a git source built from a bare branch ref with no selected version (a deliberately moving target has no stable corresponding source);
- at least one emitted member has
format = "peipkg".
The package is named <recipe-dir>-source by default (rename with
source_package.name), is noarch, and is versioned identically to the
members — members that disagree on version are a
source_package_version_conflict error. Its license is the conjunction of
the members' distinct licenses, joined with AND; its homepage is kept
only when every member agrees on one; and it publishes to the first member's
[publish] destinations.
Everything installs under /usr/src/dist/<name>-<version>/ (with any
-source suffix stripped from <name>):
upstream/— the pristine source input. For a url source, the downloaded artifact byte-for-byte, so its hash matches the committedpekit.lock; for a git source, agit archiveexport of the locked commit — deterministic for a commit and independent of the mutable checkout.patches/— the recipe's patch series, when one is declared. The applied series is the shipped series by construction, and it ships under the fixedpatches/name even when[source].patchespicks a different directory.recipe/— the build-controlling files from the recipe directory:pekit.toml,pekit.lock, package definitions (includingpackages.pekit/), env files, andkeys/.*.keyring.pekit.tomlfiles are never included — developer key material stays out.
Each binary member's manifest names the source package in
build.source_package, linking every binary to its corresponding source. The
recipe-side schema is in the
recipe format reference.
What publish checks before shipping #
publish is packaging plus shipping, and the shipping half is gated:
- Unsigned peipkg packages are refused (
unsigned_publish) unless--allow-unsignedis passed, as above. - Unanchored provenance is refused (
unanchored_provenance) unless--allow-unanchoredis passed. Unanchored means a url source with neither achecksumnor a lock entry — a state a real resolve immediately locks away, so in practice this gates dry-run publish plans for never-fetched sources. A plainpackagefrom an unanchored source warns instead. - An existing destination file is refused (
publish_exists) when the target'soverwrite = false. - Two artifacts resolving to the same destination in one run is a
publish_collision; across workspace members, the publish preflight catches the same collision before any member ships anything.
Where to go next #
For the input side of the chain — the lockfile, --repin, and upstream
signature pinning — read Sources.
For the keyring files that carry the signing key, read Environments and keyrings.
For every flag mentioned here, read the command-line reference.