Commands and targets
Every pekit invocation names exactly one command. The command decides three things up front, before any side effect: which flags are legal, how many versions the run may cover, and which targets in the recipe are selected and in what order. This page is the reference for that surface.
For the flags themselves (how they parse, global flags like --dry-run), see
Invocation and flags. For version selectors
(--version, --latest, --all-versions), see Versions.
The commands #
| Command | Selects | Versions | --all | Side effects |
|---|---|---|---|---|
build | build targets + their needs | multiple | no | Runs build targets; stages their output under out_dir. |
test | test targets + the builds they need | single resolved | no | Stages needed builds, then runs the selected test targets. |
install | install targets + the builds they need | single resolved | no | Stages needed builds, then runs the selected install targets. |
package | package members + gates | multiple | yes | Stages builds, runs gated tests, writes .peipkg artifacts under out_dir, then lints them. Recipes with a reproducible source also emit a corresponding-source package. |
publish | package members (as package) | multiple | yes | Packages as above, then publishes to configured localdir and/or Peipkg repository destinations. |
clean | one optional clean target | none | no | Runs a clean target and/or removes the managed output directory. |
gen | gen targets | none | yes | Runs gen commands; writes generated source into the tree. |
verify | gen targets | none | yes | Runs gen verify_commands; a read-only drift check (writes nothing). |
lock | the recipe's source | multiple | no | Fetches and pins the selected versions in pekit.lock without building. With no version flags, reports the current lock instead. |
lint | the recipe and every package it defines | none, or multiple | no | Checks the effective tree against lint.pekit.toml, acquiring delegated recipe files when needed; with an explicit version/source selection, also checks the payload from an existing build stage. Never builds. |
workspace | a delegated command across members | (delegated) | (delegated) | Runs one of the above across every workspace member. |
help | a command name, optionally | none | no | Prints the overview, or one command's flags and selectors. --help / -h anywhere do the same. |
version | nothing | none | no | Prints the pekit version and build commit. |
gen and verify are the source-generating pair — see
Generating source below. The rest of this
page's target model applies to build/test/install/clean; gen targets
have their own shape.
workspace is a wrapper: it parses a delegated command after its own flags and
runs it for each member. Its capabilities are exactly the delegated command's.
See Workspaces.
Version behaviour #
build, package, publish, and lock iterate over every version the
selector resolves to, running the whole plan once per version. test and install
require the selector to resolve to a single version; if more than one
resolves, pekit stops before doing any work:
invalid_version_selector: test requires a single resolved version
clean, gen, and verify never resolve versions or materialise a source
tree — they operate on the recipe directly (the output directory, or the
committed tree at the recipe root), so version-selection flags are not accepted
(see the capability matrix below).
Command / capability matrix #
Each command accepts a fixed set of flag groups. Passing a flag the command does
not support is an error up front (unsupported_flag), unless you pass
--allow-unused, which downgrades it to a suppressed warning. Broadly:
build, test, install, package, and publish share the version-selection,
local-source, --no-build, --no-verify, and --refresh-source groups;
--no-gates is package/publish only; --strict is
build/test/package/publish only; --all is
package/publish/gen/verify only; --allow-unanchored and
--allow-unsigned are publish only; clean takes only --env, --keyring, and its own mode flags
(--output-only / --target-only); gen and verify take only --env,
--keyring, and --all; lock takes version selection, --refresh-source,
and its own --repin. The full command-by-flag matrix is in the
command-line reference.
Global flags (--dry-run, --quiet, --verbose, --json, --recipe,
--allow-unused) are accepted by every command and are covered in the
invocation reference.
The target model #
build, test, install, and clean each read a same-named section from the
recipe. A section holds one or more targets, and a target is a shell command
plus optional metadata. (gen targets live in a [gen.*] section too, but have
a different shape — see below.)
Bare (main) targets #
A bare section — with a command key directly on it — defines a single target
named main:
[]
= "make -j$(nproc)"
[]
= "make check"
pekit build with no selector runs build.main; pekit test runs test.main.
The keys allowed directly on a bare section are command, needs, clear_out,
and (for [build] only) dependencies.
Named targets #
Give a section sub-tables to define named targets, [<command>.<name>]:
[]
= "make lib"
[]
= "make tools"
= ["lib"]
A target name must be a canonical selector: one or more of the characters
A-Z a-z 0-9 _ . + -, and it may not start with - or contain / or :. An
invalid name is rejected at load time (invalid_selector).
Bare and named cannot mix #
Within one section you use either the bare form or named sub-tables,
never both. Putting a command key on the section and also adding a sub-table
that is not a recognised target key is an error:
mixed_targets: cannot mix bare [build] target with named [build.foo]
Target selection #
When you name selectors (positional arguments, or arguments after --), pekit
selects exactly those targets; each must exist in the section or the run fails:
missing_target: unknown build target "libz"; available targets: lib, tools
With no selector, selection follows these rules for the command's section:
| Situation | Result |
|---|---|
Section has a main target | main is selected. |
No main, command is build, exactly one target exists | that lone target is selected. |
No main, and none of the above | error, listing the available targets. |
| Section is absent / empty | missing_target: recipe has no <command> targets. |
The single-target fallback is build-only. For test and install, a bare
pekit test with no test.main and multiple test targets is ambiguous:
ambiguous_target: no test.main target; available targets: fast, full
Build dependencies (needs) #
A target's needs lists build target names that must be staged first.
- For
build, the selected targets and everything reachable throughneedsare gathered and run in dependency order (a target runs after everything it needs). Each build runs at most once per invocation. - For
test,install, andpackage, pekit pulls the build targets named in the selected target'sneeds(forpackage, thebuildslist on the package member), stages those builds first in dependency order, then runs the selected test/install targets (or writes packages).
A needs entry that does not name an existing build target is an error:
missing_target: test.full needs missing build target "core"
Dependency edges form a DAG. A cycle is detected and reported with the path:
target_cycle: build dependency cycle: a -> b -> a
--no-build may name already-staged build targets to skip re-running them;
naming a build target that does not exist is likewise a missing_target error.
Gates #
pekit package and pekit publish run two kinds of gate: gated tests before
any artifact is written, and lint over the artifacts before any is published.
Gated tests #
A test target with gate = true is part of what the recipe's packages promise:
[]
= ["main"]
= true
= "./tests/release-check"
pekit package and pekit publish stage the union of the selected packages'
build requirements and every gate's needs, then run each gate once for the
resolved source version. Package artifacts are written only after all gates
succeed. A failed gate therefore cannot create or publish a new artifact.
A gate succeeds when its command exits successfully. Some upstream runners
print failed comparisons but still exit zero; wrapping one in make check does
not fix that. Recipes must propagate comparison failures and verify that the
intended tests actually ran. Record skipped tests and missing test prerequisites
separately from a passing gate.
Gates are recipe-wide: selecting one package from a multi-package recipe still
runs every gated test in that recipe. pekit build does not run gates, and an
explicit pekit test runs a selected target whether or not it is a gate.
The lint gate #
Once the artifacts are written, pekit runs the recipe's
lint policy over them: the static rules against the
recipe, then the payload rules against the archives themselves, read back from
the signed files rather than from the build stage. Symlinks, derived
dependencies and anything else packing produced are judged as they will ship.
Any finding stops the run before publication, leaving the artifacts on disk for
inspection. A recipe with no lint.pekit.toml in its chain skips the lint gate.
When only some of a recipe's packages are selected, the others are still
resolved from their build stages for cross-package lookups — a -devel
symlink into the runtime package, a binary's debug file in -debuginfo — but
only the selected ones are linted.
Skipping gates #
For rapid local iteration, pekit package --no-gates and pekit publish --no-gates skip both the gated tests and the lint gate, and print a warning
naming them. --no-build only controls build-stage reuse and never implies
--no-gates.
Strict mode #
--strict holds build, test, package or publish to the contract a
reviewed publication needs:
- the workspace (or, outside one, the recipe) is a git checkout with no
uncommitted changes, apart from
pekit.lockfiles, whichlock --latestmaintains unattended and which verify themselves against upstream; - every resolved source is pinned in
pekit.lock; a--localsource or an unlocked one is refused; - no bypass flag is present:
--no-gates,--no-build,--no-verify,--local,--prefer-local,--allow-unanchoredand--allow-unsignedare rejected when the command line is parsed.
On publish, --strict also has the Peipkg repository publisher check the
repository it is about to produce before writing anything. Every active package
must resolve a fresh install closure on each architecture the repository serves,
and every previously active package's closure must upgrade cleanly to the new
set. Each closure is checked for missing dependencies and capabilities,
conflicts, root placement and two packages shipping the same path. Ordinary
publish omits these checks, so a bootstrap can publish packages ahead of the
dependencies they need.
Stages that did not finish #
A stage directory on its own says nothing about whether the target that wrote
it completed, so pekit records the outcome beside it. A target marks its stage
as running before it touches anything and complete only after it succeeds, and
the marker lives in the work base's .pekit/ metadata rather than inside the
stage, which becomes a package payload.
--no-build reuses a stage marked complete. A stage whose target failed, or
was interrupted, is refused:
stage_incomplete: build:source: stage .../out/build/source is left over from a
run that did not finish; drop "source" from --no-build to re-stage it
On a normal run that rebuilds the target, pekit clears a stage explicitly
marked as still running before executing the target again, even when its
recipe does not set clear_out. This also applies in an isolated workspace:
the incomplete output is discarded, then the isolated job rebuilds from an
empty stage. A dry run neither clears the directory nor changes its marker.
This recovery does not permit reuse of incomplete output with --no-build.
An isolated workspace still refuses unverified legacy output with no
completion record, an unrecognised record, or a complete record unless the
target explicitly clears it. Records from failed root composition or an
interrupted command can therefore be retried without manual output cleaning,
while unknown legacy output keeps its existing refusal.
Without that, a failed stage is silently reused and the run fails later somewhere unrelated — a clean that gave up part-way leaves a tree too full to replace, and a fetch that failed leaves one too empty to reuse, and neither names the stage as the cause.
A stage staged before this tracking existed has no marker at all. That is
treated as unknown rather than failed and is still reused, with a note saying
its state is unverified — refusing it would turn an explicit --no-build into
a rebuild, which is the opposite of what the flag asks for.
Package stages and recipe provenance #
Each package gets its own stage under <work base>/package/, named for the
package's identity — its name, version, architecture and format — and the
finished artifact is left there for collection. Because a run packages only
the selected packages, stages of packages you did not select survive
untouched, and so do stages left by earlier runs.
An artifact records the recipe it was built from,
so a surviving stage would otherwise keep offering an artifact whose
recipe_ref describes a state of the recipe that has moved on: commit the
recipe, package one member, and every other member's stage still claims the
old commit and +dirty. To prevent that, pekit stamps each package stage with
the provenance its artifact was packed with — the recipe ref, the builder and
the source ref — and, before packing anything, drops every package stage whose
stamp does not match the current run, selected or not. The stamp lives beside
the stage in the work base's .pekit/ metadata, for the same reason completion
markers do.
Two consequences are worth knowing:
- Committing the recipe repackages, but never recompiles. Only package
stages carry this stamp. Build stages are keyed on their own inputs and keep
their completion markers, so
--no-buildstill reuses a completed build after any provenance change, and packaging repeats only the packing. - A stage packed by an older pekit is dropped. It carries no stamp, so its provenance cannot be shown to match. A package stage is a cached output rather than a build input, so repacking it costs nothing but the pack.
Two dirty work trees at the same commit produce the same recipe_ref and so
the same stamp, because +dirty marks rather than
describes:
commit the recipe when you need an artifact whose provenance identifies its
inputs exactly.
clean #
clean has two independent effects, gated by two mutually exclusive mode flags:
| Invocation | Runs clean target | Removes output dir |
|---|---|---|
pekit clean (default) | yes, if clean.main (or the named selector) exists | yes |
pekit clean --output-only | no | yes |
pekit clean --target-only | yes | no |
Details:
- Default. Runs
clean.mainif present (if there is noclean.mainand no selector, the target step is simply skipped — not an error), then removes the recipe's managed output directory (out_dir). --output-only. Removes the output directory only; no target runs. Because no target runs,--envand--keyringare unused and rejected (unsupported_flag), unless--allow-unuseddowngrades them to warnings.--target-only. Runs the clean target only; the output directory is left in place. With noclean.mainand no explicit selector this is an error:missing_target: --target-only requires clean.main or an explicit clean target.
clean accepts at most one target selector; more than one is rejected:
invalid_selector: clean accepts at most one target selector
--output-only and --target-only cannot be combined.
Pekit accepts only an out_dir strictly below the recipe root. It rejects the
recipe root itself, its parents, and paths outside it before any command runs,
so a typo such as out_dir = ".", "..", or "/" cannot make clean
recursively remove source or a broader workspace. If the child path is a
symlink, removing managed output removes the symlink rather than following it;
a symlink in any parent component of a nested out_dir is rejected.
--all for package and publish #
A multi-package recipe has several package members. package and publish
select members the same way targets are selected — by naming them, with an
optional :instance suffix for multi-package
enum instances — with two conveniences:
- With no selector and a single member, that member is used; with no selector and multiple members, the run is ambiguous and lists the members.
--allselects every member.
--all is exclusive with explicit selectors:
invalid_flags: --all cannot be combined with package selectors
On any command other than package or publish, --all is an unsupported flag.
Generating source (gen and verify) #
Some recipes have source that is generated and committed to the tree —
language bindings from a canonical header, a table baked from a schema, a file
derived from another. That is not a build: a build reads source and writes
artifacts under out_dir; a generator reads canonical inputs and writes
generated source back into the tree. pekit models it as a separate command,
gen, with a matching drift check, verify.
A gen target runs at the recipe root and its product is the files it writes
in-tree. Its $PEKIT_OUT is a pekit-managed scratch directory
(<out_dir>/.scratch/gen/<name>, cleaned on success, kept on failure) that sits
outside the artifact area — packaging and --no-build reuse never see it. It is
a handy place for a verify_command to regenerate into.
[]
# Runs at the recipe root; writes generated source in place.
= "./tools/gen-bindings.sh"
# Optional drift gate. Exit 0 = in sync, non-zero = stale. It owns its own
# comparison — pekit never diffs for you and never assumes the generator is
# deterministic. A common shape regenerates into the scratch $PEKIT_OUT and
# diffs against the committed tree:
= """
./tools/gen-bindings.sh --out "$PEKIT_OUT/fresh"
diff -ru generated "$PEKIT_OUT/fresh"
"""
# Scope of the pre-flight (see below). Omit to gate every build/test.
= ["thing-that-embeds-bindings"]
= []
[]
= "*"
The keys allowed on a [gen.<name>] target are command (required),
verify_command, verify_on_build, verify_on_test, dependencies, and
verify_dependencies. Gen targets have no needs and no build DAG — they
are independent of the build chain. Bare [gen] means gen.main, exactly like
the other sections; pekit gen --all / pekit verify --all operate on every
gen target.
pekit gen <name>runscommand— regenerate in place.pekit verify <name>runsverify_command— check only, never writes the tree. This is the on-demand / CI drift gate. Naming a gen target that has noverify_commandis an error. A barepekit verifyselectsgen.main, exactly like the other commands;pekit verify --allruns every gen target that has averify_command, and errors only when none does.
Pre-flight drift checking #
The point of verify_command is that a consuming command never proceeds from a
stale tree by accident. Before build, test, install, package, or
publish runs, pekit runs the drift gates scoped to what that command will
do, and fails fast if any is stale:
gen_out_of_date: gen target "bindings" is out of date: ...
fix: pekit gen bindings
skip: pekit build thing --no-verify=bindings
Scope is per namespace, and the two keys are separate because a [build.x] and
a [test.x] can share the bare name x:
verify_on_build / verify_on_test | Meaning |
|---|---|
| absent | Gate every target in that namespace (the safe default). |
[] (empty) | Gate nothing — the drift check runs only via pekit verify. |
["a", "b"] | Gate only when target a or b in that namespace runs. |
A gen target's gate fires when its verify_on_build gates a build the command
will run, or its verify_on_test gates a test it will run. package and
publish gate conservatively against every build target (they run builds under
the hood, and the exact set depends on package resolution). Multiple stale gates
are collected and reported together, not one at a time.
Set verify_on_build = [] for a generator whose output nothing builds (a
downstream SDK, say): leaving it absent would drag the generator's toolchain
into every unrelated build's pre-flight for no benefit. Use the scoped list
form when only some targets actually consume the generated output.
--no-verify and verify_dependencies #
--no-verify opts out of the pre-flight for one invocation:
pekit build --no-verifyskips all drift gates.pekit build --no-verify=bindings,otherskips only the named gens (an unknown name is an error).- On
package/publish,--no-verifyskips the same pre-flight — those commands gate conservatively against every build target, as above.
By default verify_command runs with the gen target's dependencies. If it
needs a different (usually smaller) toolchain — a cheap hash check rather than
a full regen — declare [gen.<name>.verify_dependencies.*]; when present it
fully replaces dependencies for the verify run.
Selector rules #
Selectors are the command's positional arguments plus everything after a --
separator. Two rules apply to all of them:
-
A selector may not start with
-. A leading dash makes pekit treat the value as a (probably mistyped) flag; put it after--instead:invalid_selector: selector "-lib" starts with '-'; use -- before selector-like values -
Target selectors must be canonical (
A-Z a-z 0-9 _ . + -, no leading-, no/or:). Package selectors follow the same grammar but allow a single:to separate a member from an enum instance.
See also #
- Invocation and flags — the grammar, flag value forms, and global flags.
- Command-line reference — the full command-by-flag capability matrix.
- Versions — the version selectors these commands accept.
- Workspaces — running a command across every member.
pekit workspace clean --output-only removes managed output without running
recipe clean targets or fetching delegated source. It needs no build environment
or keyring, even in an isolated workspace, and respects each recipe's active-job
lock. Ordinary clean targets still require the configured sandbox environment.