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

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 #

CommandSelectsVersions--allSide effects
buildbuild targets + their needsmultiplenoRuns build targets; stages their output under out_dir.
testtest targets + the builds they needsingle resolvednoStages needed builds, then runs the selected test targets.
installinstall targets + the builds they needsingle resolvednoStages needed builds, then runs the selected install targets.
packagepackage members + the builds they listmultipleyesStages builds and writes .peipkg artifacts under out_dir. Recipes with a reproducible source also emit a corresponding-source package.
publishpackage members (as package)multipleyesPackages, then publishes to a configured localdir destination.
cleanone optional clean targetnonenoRuns a clean target and/or removes the managed output directory.
gengen targetsnoneyesRuns gen commands; writes generated source into the tree.
verifygen targetsnoneyesRuns gen verify_commands; a read-only drift check (writes nothing).
lockthe recipe's sourcemultiplenoFetches and pins the selected versions in pekit.lock without building. With no version flags, reports the current lock instead.
workspacea 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:

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

[test]
command = "make check"

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

Named targets #

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

[build.lib]
command = "make lib"

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

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

Bare and named cannot mix #

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

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

Target selection #

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

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

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

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

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

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

Build dependencies (needs) #

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

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

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

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

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

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

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

clean #

clean has two independent effects, gated by two mutually exclusive mode flags:

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

Details:

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

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

invalid_selector: clean accepts at most one target selector

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

--all for package and publish #

A multi-package recipe has several package members. package and publish select members the same way targets are selected — by naming them, with an optional :instance suffix for multi-package enum instances — with two conveniences:

  • With no selector and a single member, that member is used; with no selector and multiple members, the run is ambiguous and lists the members.
  • --all selects every member.

--all is exclusive with explicit selectors:

invalid_flags: --all cannot be combined with package selectors

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

Generating source (gen and verify) #

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

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

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

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

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

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

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

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

Pre-flight drift checking #

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

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

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

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

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

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

--no-verify and verify_dependencies #

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

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

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

Selector rules #

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

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

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

See also #

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

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

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

--dry-run #

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

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

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

Output renderers #

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

Human (default) #

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

--quiet #

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

--verbose #

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

--json #

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

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

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

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

Exit codes #

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

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

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

Remote recipe locators #

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

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

The workspace.pekit.toml file #

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

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

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

[env]
SOURCE_DATE_EPOCH = "1700000000"

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

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

Member discovery #

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

Discovery proceeds as:

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

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

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

Note

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

Locating the workspace #

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

Member identity #

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

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

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

Invocation #

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

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

Strict flag placement #

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

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

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

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

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

Note

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

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-fast is set).

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

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

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

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

Cross-member publish collision detection #

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

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

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

Shared configuration #

[env] and [wrap] #

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

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

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

Signing binaries for PIP #

Package signing protects the artifact in transit; it says nothing to the kernel about the programs inside. The Peios kernel derives a process's Process Integrity Protection tier from a second, separate signature carried by the binary itself, and pekit produces that signature too — as a step of the build target, not of packaging.

A build target lists the files to sign in a sign table, keyed by signature kind. The pip kind maps paths or globs relative to the target's $PEKIT_OUT to the keyring entry that holds the signing key:

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

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

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

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

For each matched file, pekit:

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

Two properties are deliberate:

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

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

What the manifest records #

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

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

Together these state the full provenance chain — the exact upstream inputs, the exact recipe that drove the build, and the exact tool that performed it. recipe_ref deliberately resolves the enclosing repository: a workspace member's identity is the workspace repository's commit, not some per-recipe notion. 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.enabled is not set to false (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 committed pekit.lock; for a git source, a git archive export 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 fixed patches/ name even when [source].patches picks a different directory.
  • recipe/ — the build-controlling files from the recipe directory: pekit.toml, pekit.lock, package definitions (including packages.pekit/), env files, and keys/. *.keyring.pekit.toml files 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-unsigned is passed, as above.
  • Unanchored provenance is refused (unanchored_provenance) unless --allow-unanchored is passed. Unanchored means a url source with neither a checksum nor a lock entry — a state a real resolve immediately locks away, so in practice this gates dry-run publish plans for never-fetched sources. A plain package from an unanchored source warns instead.
  • An existing destination file is refused (publish_exists) when the target's overwrite = false.
  • Two artifacts resolving to the same destination in one run is a publish_collision; across workspace members, the publish preflight catches the same collision before any member ships anything.

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.

Peios Learn — documentation for the Peios project.

Built with Trail.