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 + 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.
Invocation and flags
pekit / Running pekit
This page is the reference for pekit's command line: the grammar, how flag values are written, the flags every command accepts, and how pekit decides which recipe to run. For what each command does and how it selects targets, see Commands and targets.
Grammar #
pekit [global flags] <command> [args and flags]
pekit [global flags] workspace [workspace flags] <command> [args and flags]
There is exactly one command per invocation, drawn from build, test,
install, package, publish, clean, gen, verify, lock, and
workspace. Anything before the
command that is not a recognised flag is an error (unknown_command /
unknown_flag); a missing command is missing_command.
Flags may appear before or after the command. Pekit parses flags the same way on both sides of the command word, so these are equivalent:
$ pekit --dry-run build
$ pekit build --dry-run
The one structural exception is workspace, whose tail is parsed specially: the
workspace-only flags (--fail-fast, --jobs) must come before the delegated
command, and everything after the delegated command is parsed as an ordinary
invocation of that command. See Workspaces.
-- stops flag parsing #
A bare -- ends flag parsing. Every token after it is captured verbatim and
treated as a positional selector:
$ pekit build -- ./tools ./libs
Positional selectors may not begin with -. A leading-- token before -- is
read as a flag (and rejected as unknown if it is not one); after -- it is still
rejected as a selector with the diagnostic "use -- before selector-like
values". Use -- for selectors that merely look option-like (for example a
path), not to pass a selector that begins with a dash.
Flag value forms #
Flags fall into three shapes — no-value, required-value, and optional-value — and the shape decides how a value may be written. Which shape each flag takes is tabulated per flag in the command-line reference; the rules are as follows.
No-value flags (--dry-run, --json, and the like) never take a value:
--flag=x is an error (unexpected_flag_value).
Required-value flags accept either the separated form (--recipe path) or the
= form (--recipe=path). With the separated form pekit consumes the next token
as the value — unless that token is missing, is --, or itself begins with -,
in which case the flag errors with missing_flag_value. With the = form an
empty value (--recipe=) is rejected the same way.
Optional-value flags take a value only through =. They never consume the
following token. So:
$ pekit build --local # --local with an empty value
$ pekit build --local=/src/mypkg # --local with a value
$ pekit build --local /src/mypkg # --local (empty) + /src/mypkg as a selector
-V is an alias for --version and behaves identically (it is a required-value
flag; it is not a version-print flag — that is the pekit version command).
Per-key keyring values use a dotted-path form, --keyring.<path>=<value>, which
always requires the = and a non-empty path.
Global flags #
Seven flags are parsed in any position regardless of command: --recipe,
--workspace, --allow-unused, --dry-run, --quiet, --verbose, and
--json. Six are accepted by every command; the seventh, --workspace, is only
valid with the workspace command. Each is covered in its own section below,
and the command-line reference tabulates all seven with
their value forms. The rest of pekit's flags (--version/-V, --latest,
--local, --env, --keyring, --all, and so on) are command-specific and
are covered on Commands and targets.
--recipe <path|dir> selects the recipe directly instead of searching upward
from the cwd (see How a recipe is located);
--workspace <path|dir> does the same for the workspace file.
Mutual-exclusion rules #
Validation rejects contradictory combinations up front, before any work runs:
--quiet cannot be combined with --verbose or --json; --recipe cannot be
combined with --workspace (and --workspace is rejected outside the
workspace command); the version selectors (--version / --latest /
--all-versions), the local-source flags (--local / --prefer-local), and
the clean modes (--output-only / --target-only) are each mutually exclusive
within their group. The complete validation table, with each diagnostic, is in
the command-line reference.
--allow-unused #
Every command declares which flags it accepts. Passing a recognised flag that
the effective command does not support is normally an error
(unsupported_flag), for example version selection on clean, or --all on
build. --allow-unused turns each such case into a non-fatal notice: pekit
records it and emits an unused_suppressed event at the start of the run instead
of aborting.
--allow-unused only relaxes the "recognised-but-unused" check. It does not
excuse:
- unknown flags (
unknown_flag) or unknown commands (unknown_command); - malformed flag values — missing values, empty
=values, or=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. pekit help and pekit help <command> give the one-screen summaries;
this page, Commands and targets, and
the command-line reference are the manual.
How a recipe is located #
Pekit resolves exactly one recipe per (non-workspace) run:
--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. An existing local path, resolved relative to the invocation
directory, takes precedence for both recipe and workspace locators. For example,
--recipe com.git-scm.git selects that local recipe directory without needing a
./ prefix. A value with no local match is treated as remote when it does not start with .
or / and it either begins with github.com/, contains ://, ends with .git,
or contains a // subdirectory separator. Examples:
$ pekit build github.com/acme/tools
$ pekit build github.com/acme/tools//subdir@v1.2.0
$ pekit build https://git.example.org/acme/tools.git//pkg@main
The locator may carry a //subdir to select a directory inside the repository and
an @ref (branch, tag, or commit) to pin a revision. When pekit sees a remote
locator — whether as the positional or as the value of --recipe — it
materialises it: it mirror-clones the repository into a cache under the
system temp directory (fetching updates if already cached), checks out the
resolved commit into a clean working tree, and then treats the checkout (plus any
//subdir) as the recipe root. From there the run proceeds exactly as it would
for a local recipe.
See also #
- Commands and targets — what each command does and selects.
- Command-line reference — every flag, its value form, and the validation table.
- Workspaces — the workspace tail and its flag placement.
- Sources — what materialising a remote recipe's source involves.
Workspaces
pekit / Running pekit
A workspace lets you drive a whole collection of recipes as one unit. Instead
of running pekit build in each recipe directory by hand, you declare which
recipes belong together in a workspace.pekit.toml file and run a single
pekit workspace build, which delegates that command to every member. This is
how a distro-scale tree of packages — dozens or hundreds of recipes — is built,
tested, packaged, and published in one go.
A workspace does three things:
- 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/*"]
[]
= "C"
[]
= ["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 N,--fail-fastand--journal <file>— 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 the workspace flags are recognised only in the workspace slot,
writing them after the delegated command hands them to the delegated command's
own parser, which rejects them as an unknown_flag. Keep them in the workspace
slot.
Both failures share the diagnostic code missing_workspace_command: it fires
when a command flag precedes the delegated command, and when no delegated
command follows the workspace flags at all ("workspace requires a delegated
command").
Execution model #
Each member is run as an ordinary per-recipe invocation of the delegated
command — exactly as if you had cd'd into that member and run
pekit <command> ... there, with the delegated flags and any positional
selectors carried through unchanged.
A failed member's explicitly incomplete target stage is cleared when that
target is rebuilt on a later run. This applies under workspace isolation as
well; it does not allow --no-build to reuse incomplete or unverified output.
See Stages that did not finish.
Concurrency: --jobs #
--jobs N sets how many members run concurrently. N must be a positive
integer; a missing value is missing_flag_value and a non-positive or
non-numeric value is invalid_flag_value ("--jobs requires a positive
integer"). The default is 1, i.e. members run one at a time, in
dependency order.
Fail-fast: --fail-fast #
By default the workspace attempts every member even if some fail: it runs
them all and then fails the overall command at the end if any member failed
(workspace_failed, "N workspace member(s) failed").
With --fail-fast, the first member failure sets a stop flag so that no
further members are started. Members that had not yet begun are recorded as
skipped.
Resumable runs: --journal #
--journal <file> makes a long run resumable. Each member that succeeds is
appended to the journal as it finishes, and the file is synced, so it survives
a power loss straight afterwards. Repeating the same run with the same
journal skips the members it lists (they count as skipped in the summary) and
runs the rest. A skipped member still takes part in
dependency order: the packages it defines carry their
runtime dependencies, so a member installing one still waits for whatever
those need. Members that failed, or never started, are not listed, so they
run again.
To stop a run cleanly, create <file>.stop. At the next point where a member
finishes, pekit starts nothing more, waits for the members still running, and
exits with workspace_stopped. It removes the stop file as it honours it, and
also removes one left over when a new run starts. Anything stopped by other
means, such as a power-off, loses only the members that were running.
$ pekit workspace --jobs 3 --journal round2.journal publish --all --latest --locked
$ touch round2.journal.stop # later: finish what is running, then stop
$ pekit workspace --jobs 3 --journal round2.journal publish --all --latest --locked
The journal's first line records the run it belongs to: the delegated command,
version selection (including --locked), --all, --replace, --strict,
--no-gates, --env, tags and selectors. A journal from a different run is
refused with journal_mismatch, so one round's journal cannot skip another
round's members. --jobs and --fail-fast are not part of it and may change
between attempts. --dry-run reads an existing journal but never creates or
writes one. The journal is opt-in; without it a workspace run behaves as
before.
Dependency order #
For build, install, test, package and publish, pekit orders members so
that a member producing a package runs before the members that install it. The
aim is the best chance of a whole batch succeeding, even into an empty
repository.
A member waits for another when the dependencies of its gate targets name a
package the other member defines. Gate targets are the ones the command
installs dependencies for: build targets for build and install, build and
test targets for test, and build targets plus gated tests
for package and publish (only build targets under --no-gates). Only the
dependency set of the selected env's dependency_provider counts: under an
env whose provider's names no member produces (a foreign distribution's
packages, say), there are no edges and members are independent.
Runtime dependencies count too. When a member needs a package another member
defines, it also waits for whatever that package's own runtime
[dependencies] name, followed
transitively through member packages, provided the package declares the same
dependency_provider. Names
no selected member defines are not ordered: they resolve from whatever the
provider already has, as before.
Scheduling:
- Among members whose waits are satisfied, lower ids start first;
--jobsruns several at once. - A member starts even if one it waited for failed. It then resolves that dependency from what the provider already has.
- A dependency cycle cannot be ordered. When nothing is runnable and nothing is running, pekit starts the blocked member with the fewest unfinished dependencies (lowest id on a tie) and carries on. There is no warning: building a real cycle from nothing needs a separately built seed, which tags let you select.
Ordering reads each member's effective recipe, resolving its source as the
member's own run will. Under --dry-run only already-cached sources are
visible, and --dry-run or --verbose reports each member's waits as
workspace_order events. A member whose recipe cannot be resolved is not
ordered, and reports its failure when it runs.
Selecting members by tag #
A recipe may label itself with tags:
# pekit.toml
= ["bootstrap"]
After the delegated command, --tag <tag> runs only members carrying any
given tag, and --exclude-tag <tag> skips members carrying any given tag.
Both repeat, apply after include/exclude, and fail with empty_workspace
when nothing matches. Tags are catalogue policy, so only the member recipe's
own tags count, never a delegated source's.
Tags and ordering together are how a distribution bootstraps from nothing: build
the tagged seed in an environment that needs none of the catalogue, then
everything else in dependency order, then the seed again natively, then
everything else against the native seed. Here the workspace's default profile is
the native one, and debian is the named profile used only for the seed. The
later rounds publish versions that already exist, so they overwrite them with
--replace, which is only for a repository nobody
consumes yet:
$ pekit workspace publish --all --latest --tag bootstrap --env debian
$ pekit workspace publish --all --latest --exclude-tag bootstrap
$ pekit workspace publish --all --latest --tag bootstrap --replace
$ pekit workspace publish --all --latest --exclude-tag bootstrap --replace
Summary #
At the end of a run pekit emits a workspace_summary event tallying members as
succeeded, failed, or skipped:
12 succeeded, 1 failed, 3 skipped
Succeeded and failed count members that actually ran; skipped covers
members not started because of --fail-fast or a stop request, members
already finished in the journal, and members skipped
by selector planning (below). The overall command exits non-zero if any member failed.
Per-member strictness #
The delegated command and its flags are validated once, when the tail is parsed.
Beyond that, strictness is evaluated per member, because members differ: one
recipe may define a build target or a package selector that another does not.
-
Each member runs the delegated command against its own recipe. If a flag or selector cannot be honoured by that member's recipe, that member errors — its failure is reported (
member_failed) and counted, and the other members are unaffected (unless--fail-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 requirements #
The [policy] table carries distro-wide derivation policy.
[policy.symbol_versions] is a map from a shared-library soname
to the symbol-version token prefix whose tokens are commensurable with the
providing package's version:
[]
= ["libstdc++.so.6"]
[]
= "GLIBC_"
= "GCC_"
This governs which sonames receive a symbol-version floor when pekit derives a
package's dependencies across the workspace — so that, for example, a binary
using a GLIBC_2.34 symbol gains an appropriate lower bound on its libc
dependency. An unknown sub-table under [policy] is rejected
(unknown_key, "policy.… is not a known policy table"). For how derived
dependencies and claims work, see
Dependencies and claims.
symbol_capabilities derives every required and provided version node directly,
including both GLIBCXX and CXXABI for libstdc++. Its token numbers are not GCC
package versions. Publish capability-bearing runtimes before consumers.
Where to go next #
For the recipes a workspace's members are, read Anatomy of a recipe.
For how workspace [env] and [wrap] compose with a recipe's own, read Environments and keyrings.
Named env files are workspace defaults too. A selected
<name>.env.pekit.toml at the workspace root applies to every member; a
same-named file beside one recipe overlays it. This lets a workspace define
shared build rungs without per-member symlinks.
For the full flag surface and remote locators, read Invocation and flags.
Signing and provenance
pekit / Running pekit
A published package is a claim: these bytes were built from these inputs, by
this recipe, with this tool. Pekit backs that claim end to end. On the input
side, fetched sources are pinned trust-on-first-use in
pekit.lock and upstream release
signatures can be verified against committed keys — both covered on the
Sources page. This page covers the output side:
signing the artifacts pekit writes, the provenance recorded in every
manifest, the corresponding-source package that keeps a build's exact inputs
publishable, and the checks publish runs before shipping anything.
Signing artifacts #
Package signing is configured through one well-known keyring entry,
signing.package_key, whose value is the path to an Ed25519 private
key — either the raw 32-byte seed or a PKCS#8 PEM as produced by
openssl genpkey -algorithm ed25519. A relative path resolves against the
invocation's working directory. Because it rides the
keyring mechanism, the key path
lives in a per-developer, gitignored file — or arrives inline as
--keyring.signing.package_key=<path> — and private key material never
enters a recipe.
$ pekit publish --keyring=dev
With a key configured, every peipkg-format artifact the run produces —
package members and the corresponding-source package alike — is packed with
an embedded signature naming the key's fingerprint (the lowercase hex SHA-256
of the raw 32-byte public key), and each emits a sign event. Sign events
stay visible under --quiet, so a quiet run still shows what was signed with
which key.
Two failure behaviours are deliberate:
- A configured key that cannot be loaded is a hard
signing_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.
A [publish.peipkg] target additionally needs the private key that signs its
repository descriptor and indexes. Its signing_key may name a key file
directly or use keyring:<dotted.entry> to read a key-file path from a keyring,
for example keyring:signing.repository_key. This is deliberately separate
from the well-known signing.package_key: one key may perform both roles, but
the repository publisher also supports distinct package and metadata keys.
When Pekit initializes a repository it records both public keys when they differ. When publishing to an existing repository, that repository's signed descriptor must already trust the relevant keys; Pekit never widens an existing trust set as a side effect of publishing.
Signing binaries for PIP #
Package signing protects the artifact in transit; it says nothing to the kernel about the programs inside. The Peios kernel derives a process's Process Integrity Protection tier from a second, separate signature carried by the binary itself, and pekit produces that signature too — as a step of the build target, not of packaging.
A build target lists the files to sign in a sign table, keyed by signature
kind. The pip kind maps paths or globs relative to the target's $PEKIT_OUT
to the keyring entry that holds the signing key:
[]
= "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 any of the build's own inputs are uncommitted — including a freshly written, not-yet-committed pekit.lock. Omitted when the recipe is not inside a git work tree. |
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.
What makes a recipe dirty #
+dirty answers one question: did anything that could change this artifact
differ from that commit? So the marker covers the build's own inputs, and
nothing else:
| Counts as dirty | Does not |
|---|---|
The selected recipe directory — pekit.toml, its package definitions, pekit.lock, source patches, embedded upstream keys | Other recipes in the same repository, whether or not workspace fan-out selects them |
Inherited workspace inputs beside the members — workspace.pekit.toml, shared package defaults, environment files, keyrings | Pekit's managed output: a member's out_dir, as a real directory or as a link into shared staging |
| Anything else uncommitted inside those paths, tracked or not | The destinations a run publishes into, including a local peipkg repository |
Within that scope a dirty tree is over-marked rather than under-marked,
because a commit id alone does not describe a build whose inputs had local
changes: pekit marks the artifact, rather than trying to decide which edits
mattered. That is also why two differently dirty work trees at the same commit
produce the same recipe_ref — +dirty says the commit is not the whole
story, not what the rest of it was.
A shared recipe repository is the reason for the scope. Without it, editing any
package taints the provenance of every package published from that repository
until the edit is committed, and an operational output or repository link
beside a member taints an otherwise exact commit — which is not something a
repository should have to solve with .gitignore entries.
Provenance also decides how long a staged artifact stays usable. Package
stages are stamped with the recipe_ref, builder and source_ref they were
packed under, and a packaging run drops every stage stamped differently before
it packs anything — so committing the recipe, or dirtying the tree, cannot
leave an artifact behind whose recipe_ref describes the recipe as it used to
be. Only packing repeats; completed build stages are untouched and
--no-build still reuses them. See
Package stages and recipe provenance.
Corresponding-source packages #
A recipe with a reproducible source automatically emits a
corresponding-source package whenever it packages peipkg-format
members. This is how the distribution meets copyleft source-availability
obligations: publishing a binary and its source package through the same
channel keeps the exact inputs of every published build available for as long
as the binary is.
One source package covers every peipkg member the recipe produces. It is emitted when all of these hold:
source_package.enabledis not set tofalse(emission is the default);- the source actually materialised as git with a resolved commit or url/PyPI — a local override never qualifies, and neither does a git source built from a bare branch ref with no selected version (a deliberately moving target has no stable corresponding source);
- at least one emitted member has
format = "peipkg".
The package is named <recipe-dir>-source by default (rename with
source_package.name), is noarch, and is versioned identically to the
members — members that disagree on version are a
source_package_version_conflict error. Its license is the conjunction of
the members' licenses and any source_package.license. The recipe declares
that key for material only the source carries, such as a test program no member
ships. Terms are deduplicated and sorted, and a term containing OR stays
parenthesised. Its license class is the
most restrictive class declared by any member, so an all-free family remains
free, while an omitted or unknown member makes the corresponding source
package unknown. Its homepage is kept only when every member agrees on one;
and it publishes to the first member's [publish]
destinations.
Everything installs under /usr/src/dist/<name>-<version>/ (with any
-source suffix stripped from <name>):
upstream/— the pristine source input. For a url source, the downloaded artifact byte-for-byte, so its hash matches the committedpekit.lock; for an ordinary git source, agit archiveexport of the locked commit — deterministic for a commit and independent of the mutable checkout. For tracked-path git, that archive is restricted to the one locked relative path; no other repository file enters the source package. A URL source's upstream-maintained patch series is included byte-for-byte underupstream/patches/, in the same order and with the same hashes as its nested lock entries.source/— the prepared source tree before package targets run, including the applied upstream and distribution patches. Reconstruction uses this tree directly, without fetching the upstream source or applying patches again.workspace/— the captured recipe tree at its original relative path, inherited workspace/package/lint policy, environment profiles, and declared shared inputs. Recipe-local test fixtures, tools, scripts and license files are included. Safe relative symlinks retain their topology; missing or escaping targets fail packaging. Empty fixture directories are preserved.recipe/andpatches/— compatibility views for older source consumers. New reconstruction tooling uses the completeworkspace/topology.acquisition/vendor/— captured outputs of an isolatedbuild.vendorstage, when it ran, including vendored language dependencies.build-environment/— dependency versions and artifact identities recorded by the selected root preparers. The catalogue Debian profile records an immutable prepared-root archive and supports explicit replay below. Native repository replay remains a separate provider contract.build-inputs.json,rebuild.pyandREBUILD.md— the versioned bundle manifest, reconstruction entry point and required tools. The manifest records file hashes, modes, link targets, the upstream version/ref and selected environment. Peipkg transport modes differ from source Unix modes; the reconstruction tool restores source modes after all hashes verify.
Small source bundles use schema 2. At 90,000 payload entries, Pekit uses
schema 4 and stores the prepared tree in deterministic prepared-source.tar.gz.
Compressing this inner archive leaves room for the pristine upstream archives
and acquisition inputs within the outer package's 4 GiB decompressed limit.
Individual source hashes, modes and link targets remain included. This also
keeps the outer package within Peipkg's 100,000-entry limit without dropping
source files. Both the compressed prepared archive and its decoded tar stream,
including headers and padding, are limited to 4 GiB. Too many remaining
non-source entries or an identity manifest exceeding 64 MiB fails packaging.
The identity manifest uses readable JSON when it fits, and compact JSON when
removing whitespace is needed to fit the same limit. Every recorded identity and
metadata field is preserved; compact manifests that still exceed the limit fail.
rebuild.py accepts schemas 2, 3 and 4. Historical schema-3 bundles retain their
uncompressed prepared-source.tar. For either archived form, it checks the
archive hash before reading it, extracts into a fresh temporary directory and
verifies every member against the identity manifest before moving the tree into
place. Missing, duplicate, escaping, sparse, hard-link and special-file entries
fail verification. Relative symlinks must resolve within the prepared tree.
Extraction does not use tar.extractall; it bounds paths, decoded bytes and
extended-header reads. Compressed streams are checked through their trailer,
including the checksum. An existing prepared tree is verified before reuse.
Verify the outer package signature before running any included script: these
checks detect changed inputs and do not make an unsigned script trustworthy.
An isolated job copies shared helpers before any target runs. Workers consume
those read-only copies, and the source package contains the same bytes. Recipe
and policy inputs are captured separately from writable worker trees. Editing
those inputs during a job, or before reusing its outputs, causes packaging to
fail with source_input_changed; rebuild with the new inputs. Retained outputs
from before source-input capture was introduced also require one fresh build.
The exporter excludes output/cache state, Git internals, ignored developer files, keyring files, known credential filenames and configured key-file paths. An explicitly declared input that is missing, ignored or escapes the workspace is an error. Keep credentials out of tracked recipe material. Copying build inputs does not authorize distributing private keys.
Workspace isolation.inputs are captured automatically. A recipe can add
source_package.workspace_inputs = ["helpers/python"] for its own read-only
shared inputs. Coordinator-side support, such as root preparation scripts,
belongs in workspace [source_package] inputs = ["_peiroot_", "_debroot_"];
this captures source without granting workers access to those directories.
Reconstructing a source bundle #
Verify the outer package signature using the distribution's trust configuration,
then extract it and run python3 rebuild.py test or python3 rebuild.py package
in its /usr/src/dist/<name>-<version>/ directory. The command verifies captured
hashes, restores source modes, and invokes Pekit with the included prepared tree,
the recorded version, and the original environment. PEKIT_REBUILD_ENV can
select another captured environment. Additional Pekit flags follow the command.
Install a compatible Pekit, Python 3, Bubblewrap and the selected profile's root
preparation tools. A native profile still needs its declared trusted package
repository; a fresh Debian environment needs Docker and archive access; retained-root replay
needs neither. For the catalogue,
provide that repository at workspace/_peipkgRepo_. These dependency services and
operator signing keys are supplied separately. A generic reconstruction may
rerun the declared language acquisition stage; its captured vendored sources
are also available for language-specific offline replay.
Retaining and replaying Debian build environments #
The catalogue's Debian root preparer separates networked dependency acquisition
from Pekit's offline build and test workers. A fresh job pulls the current
debian:trixie image once, records its immutable image ID and repository digests,
and creates every Debian root for that job by ID. APT package requirements use
concrete names, optionally qualified by architecture. A constraint is * or a
comma-separated conjunction of =, >=, >, <= and < comparisons. Debian
version ordering applies, including epochs and ~; semver ranges and virtual
package requests are not accepted. Before a single joint install, the preparer
filters available versions against the constraints, permitting a necessary
downgrade only inside the disposable root. It checks all final installed direct
dependencies after resolution. Failed archive refreshes and unsatisfied or
unsupported requirements stop acquisition.
Each completed root is exported and retained as <sha256>.tar.gz. Its contents
include installed package files, the dpkg database, APT configuration and signed
index metadata. The per-target debian-root.json records the requests, base
image, host architecture, preparer hash, archive hash/size and package inventory
hash. installed.tsv includes package name, version, architecture and dpkg state.
Both records enter the corresponding source bundle under build-environment/.
Large root archives remain in coordinator storage, outside source bundles and
worker mounts.
The default archive store is
${XDG_STATE_HOME:-$HOME/.local/state}/pekit/debian-roots.
PEKIT_DEBIAN_ROOT_STORE selects another directory, for example a backed-up
release archive volume. Archives are published atomically, verified before
reuse and extracted into fresh roots. Ordinary build cleanup cannot delete the
store. The preparer does not garbage-collect it: retain or back up every archive
referenced by a supported release's records. Source bundles alone do not contain
these dependency bytes. Deleting a referenced archive makes that historical
environment unavailable; the preparer does not silently reconstruct it from a
new archive state.
After verifying and extracting a corresponding-source package, replay its recorded Debian environment with:
Supply these variables to the coordinator process, not recipe [env] tables.
Replay requires Python 3.11+, GNU tar, Pekit and Bubblewrap, but invokes no Docker
or APT commands. It requires matching captured preparer bytes, architecture and
canonical requested dependencies, and verifies both inventory and archive
hashes. Every executed Debian target must have its own record; missing, corrupt
or incompatible records fail without a network fallback.
Language acquisition commands retain their explicitly declared network policy;
retaining the root alone does not turn a cargo vendor invocation into offline
source replay.
Reconstruction uses local provenance and does not claim a byte-identical signed release. Retained-root replay fixes the dependency filesystem; the host kernel, CPU capabilities, runtime mounts, clock, signing identities and independent reproducibility/publication qualification remain separate checks. New ordinary builds continue following upstream automatically; no manually maintained version pins are added.
Recipes with no reproducible external source, and explicit --local builds,
still do not emit an automatic source package. For in-repository products,
distribute the complete catalogue commit/tree (including shared support and
policy) with the release and record that identity, or give the product a
reproducible Git source so automatic source packaging applies. A binary alone
and a moving repository URL are not a complete corresponding-source offer.
Each binary member's manifest names the source package in
build.source_package, linking every binary to its corresponding source. The
recipe-side schema is in the
recipe format reference.
What publish checks before shipping #
publish is packaging plus shipping, and the shipping half is gated:
- Unsigned peipkg packages are refused (
unsigned_publish) unless--allow-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. - A Peipkg repository publication verifies every package before changing its
indexes. An untrusted package signer, duplicate package identity, damaged
artifact, or unusable repository metadata key is a
peipkg_repository_publisherror.
Where to go next #
For the input side of the chain — the lockfile, --repin, and upstream
signature pinning — read Sources.
For the keyring files that carry the signing key, read Environments and keyrings.
For every flag mentioned here, read the command-line reference.
Kernel module signing #
[]
= "modsig.priv"
= "modsig.priv"
The keyring entry names an ML-DSA-65 PKCS#8 PEM private key followed by its X.509
certificate. The coordinator signs the uncompressed relocatable ELF bytes,
verifies the detached CMS signature against that certificate, appends the kernel
signature trailer, and recompresses .ko.zst outputs. It uses its trusted OpenSSL,
never the build tree's scripts/sign-file. OpenSSL 3.5 authenticated attributes
require the kernel's ML-DSA auth-attribute compatibility setting.
Workers need only the public certificate in modsig.pub, granted with
access = "public" and requested through keyring_inputs. Kernel compilation
keeps signature enforcement enabled; modules_install suppresses its own signing
invocation with CONFIG_MODULE_SIG_ALL=. The coordinator then signs both module
sets before dependent tests run. Firmware tests similarly prepare fixture blobs
in a build target, apply sign.pip in the coordinator, and consume those signed
fixtures in the test worker. Production private keys never enter worker source or
output trees.
Linting
pekit / Running pekit
pekit lint checks a recipe against a set of rules and reports every
violation in one pass. It is the mechanical half of a packaging review: the
things a person would otherwise verify by reading the recipe and running
readelf over the build, written down once and run everywhere.
pekit ships the rules but enables none of them by default. A
lint.pekit.toml file turns rules on and sets their parameters, so a
distribution decides what its recipes are held to and a standalone project
decides the same for itself. A tree with no lint file has nothing to lint,
and pekit lint says so (missing_lint_config).
Every rule is an error. There is no warning level: a rule is either on, or exempted for a stated reason. A warning would be a finding nobody has to justify.
Running it #
pekit lint
pekit lint --version 1.5.7
pekit workspace lint --json
With no version flag, an ordinary non-delegated recipe reads only its committed
recipe files, pekit.lock, and patch series. It resolves no source and touches
no network.
A delegated recipe is the deliberate exception. Plain pekit lint acquires
one source snapshot, loads the delegated pekit.toml, package files, and
lint.pekit.toml, merges them with the wrapper, and runs static rules against
that effective recipe. A templated, tracked-path, or PyPI source selects the
newest discoverable version; a fixed Git ref or fixed URL needs no synthetic
version; a local-only delegate uses its configured [source.local] tree. This
may contact the source and populate out_dir's source cache, but it runs no
target and needs no build stage.
With an explicit source/version flag (--version, --latest, --local),
lint performs the same effective-recipe checks and additionally checks the
payload each package would pack, read from an existing build stage. It
never builds: a missing stage is stage_missing, naming the pekit build to
run first. The rules that need a payload are marked in the tables below;
without an explicit selection they are skipped with a lint_skipped event
that says how many.
lint takes the version-selection and local-source flags, --env and
--keyring, and the global flags. It accepts no selectors and no --all:
every package a recipe defines is checked. pekit workspace lint fans out
across every member like any other command. A versioned run applies the
selected environment's [lint.allow]
exemptions, as the package gate does.
pekit package and pekit publish also run this policy, as the
lint gate: the static
rules against the recipe and the payload rules against the archives the run
just wrote. Any finding stops the run before publication.
Output is one lint event per finding, with rule, package and path
fields under --json, an event per exemption that applied (lint_allowed),
one per exemption that did not (lint_unused_allow), and a lint_summary.
The command fails with lint_failed when any finding stands.
The lint.pekit.toml file #
A rule is enabled by giving its key a value. A boolean rule takes true; a
rule with a real choice takes one of its enumerated strings; a rule with a
parameter takes the parameter. An absent key is a rule that does not run.
[]
= "reverse-dns"
= "spdx"
= true
= 80
[]
= true
= true
= true
[]
= true
= "full"
A rule's ID is its key path in the file: package.name.style,
elf.relro. That ID is what findings carry, what [allow] names and what
this page lists. Unknown keys are rejected (unknown_key), as is a value of
the wrong shape (invalid_value), so a misspelled rule cannot silently not
run.
Where files are found, and how they layer #
Files are found by walking up from the recipe directory to the workspace root. A recipe outside a workspace reads only its own, except that a delegated recipe also reads the file at its acquired source root. Plain lint performs that acquisition automatically; it is not necessary to request payload lint just to apply the source project's rules.
They merge outermost first, nearest winning per key, in this order:
- the delegated source root, when there is one;
- the workspace root;
- each directory down to the recipe directory;
- the recipe directory.
The source root comes first, not last, deliberately. A project may carry a lint file that holds itself to a higher bar than the distribution packaging it, but it cannot relax the distribution's rules.
A nearer file may re-parameterise a rule (description = 40 under a
workspace that says 80) but may not switch one off. Setting a rule
that an outer file enabled to false is refused (lint_rule_disabled).
Switching a rule off goes through [allow].
[allow]: exemptions with reasons #
For a payload exception limited to particular files, prefer allow_files:
[]
= "startup object linked into user programs"
Keys are case-sensitive, package-root-relative payload paths or glob patterns
(* matches within one path component; ** can cross directories). They match
destinations, not build-host source paths. Every entry requires a nonempty reason
and a known payload rule; absolute paths, parent traversal and malformed patterns
are rejected. Other files and other rules remain enforced. Exceptions apply to
all splits of the recipe, so keep paths narrow. Configurations merge per rule and
pattern, nearest winning for the same pair. Matching findings remain visible as
lint_allowed, and unused entries produce lint_unused_allow. A whole-rule
allow takes precedence; otherwise the first matching pattern in lexical order
is used. Neither form enables a rule that is disabled or absent.
Use a whole-rule exemption only when it genuinely applies to the entire recipe:
[]
= "non-public: grants unauthenticated SYSTEM access to a DWE guest"
= "upstream ships no manual pages; tracked in PEI-27"
An [allow] entry names a rule and gives a reason. The reason is mandatory
(missing_reason) and may not be empty. While the entry stands, that rule's
findings for the recipe are reported as lint_allowed rather than failing
the run. An entry that never applies is reported as lint_unused_allow, so
an exemption outlives its cause visibly and can be removed.
[allow] may appear in any file in the chain, but it belongs as close to
the recipe as the exemption is true for. Under --json, a workspace-wide
pekit workspace lint lists every exemption in the tree and its reason: the
review list of what is not yet up to standard and why.
Rules #
Each rule is general packaging hygiene, of the kind lintian, rpmlint, rpminspect or a Homebrew audit also checks. Anything distribution-specific is a parameter, never a value baked into pekit. Rules marked payload need a staged build and run only with a version flag.
[package] #
Checked for each package definition the recipe produces.
| Rule | Value | Checks |
|---|---|---|
package.name.style | "reverse-dns" or "lowercase" | Every package name, and the recipe directory when the recipe is a workspace member, matches the style. Reverse-DNS is org.example.thing; lowercase is any [a-z0-9._+-] name. |
package.license | true or "spdx" | A license is declared. With "spdx", the expression also parses as SPDX: identifiers joined by upper-case AND / OR, WITH for exceptions, balanced parentheses. The SPDX license list itself is not consulted. |
package.license_class | true | A peipkg-format package declares license_class. Other formats are not checked. |
package.homepage | true or "https" | A homepage is declared; with "https", it is an https URL. |
package.description | true or a length | A description exists, is one line, is at most the given length (80 when true), does not end with a full stop, and does not start with the package name or its last dotted label. |
package.dependencies | "consistent" | No self-dependency; no dependency on a name the package itself provides; no name both depended on and conflicted with; no name in both [dependencies] and [optional_dependencies]; no provide or conflict on the package's own name; no dependency, optional dependency or provide whose version spells out "{{version}}-N" by hand (use "= {{release}}"). |
package.references | "reverse-dns" | Every concrete package reference in package metadata and every Peipkg target dependency uses its canonical reverse-DNS name. Structured virtual capabilities such as pkgconfig(foo), python(abi) and ELF SONAMEs remain valid. Other deliberately unqualified interfaces must be listed in package.virtual_capabilities. |
package.architecture | "consistent" payload | A package declared architecture-independent ships no ELF object and nothing under an architecture-specific directory; a package declared for an architecture ships at least one such thing. The architecture-independent name is the package.noarch parameter, default noarch. |
Architecture-specific directory inference uses wildcard entries in
payload.dirs.lib, such as usr/lib/*-linux-*, and the split-debug directory
usr/lib/debug. Source text under usr/src/debug does not become
architecture-specific merely because of that directory name. ELF objects are
still detected there. Portable scripts and data may remain noarch when they
depend on native packages: those dependencies are resolved for the target
machine, not inherited as the portable package's architecture. Actual ELF or
architecture-specific directory content in a noarch package is still reported.
For an explicitly architecture-constrained source or header split, a required
architecture-specific sibling can justify that constraint. This payload check
cannot infer external dependency architectures; a genuinely architecture-tied
package without visible payload or sibling evidence needs a documented exception.
package.virtual_capabilities is an array parameter shared by the
package.references checks. It is for genuine interchangeable interfaces such
as sh or an operating-system role, never for a compatibility alias for a
concrete package. For example:
[]
= "reverse-dns"
= ["init", "sh"]
[source] #
Checked against the recipe's [source] table. Rules for a source kind the
recipe does not use do nothing.
| Rule | Value | Checks |
|---|---|---|
source.reproducible | true | The source is not [source.local] alone. A recipe with no source at all passes. |
source.discovery | true | A URL source has listing_url; a git source (other than a tracked path) has tag_regex. Without them new upstream releases cannot be found, and every update is by hand. |
source.versions.floor | true | A discovering source has a versions constraint with a lower bound: >=, >, =, ^ or ~. A < x ceiling alone, *, or no constraint follows any release upstream ever published, including ones older than the recipe. Ceilings are never required. |
source.versions.ceiling | "none" | No versions constraint bounds from above: no < or <= term, no = pin, no ^ or ~ range. A ceiling is where unattended updates silently stop; a new major is reviewed when it arrives, not fenced off in advance. |
source.ref | "immutable" | A git ref is a template (v{{version}}), a full commit hash, a refs/tags/ path or a tag-like v1.2, not a branch name that the lock could never pin. |
source.url.scheme | "https" | Every source URL (url, listing_url, signature and patch-series URLs) is https. A git URL may also be ssh://, git+ssh:// or user@host:path. |
source.lock | true | pekit.lock exists with at least one entry; every URL entry carries a sha256 (and each patch its own); every git entry a commit hash; and, when the recipe verifies signatures, every URL entry records the key that verified it. |
source.signature.required | true | A URL source has a [source.url.signature] block, and a git source (tracked paths included) a [source.git.signature] block. An upstream that publishes nothing verifiable is recorded as a reasoned allowance. |
source.signature.fingerprint | "full" | A signature block lists at least one fingerprint, and each is the full 40- or 64-hex-digit fingerprint, never a short key id. |
source.signature.keys | true | Every key_files entry exists and is not empty. |
source.patches.headers | true | Every patch in the series has, ahead of its first hunk, a non-empty Description: or Subject: line and an Origin:, Author: or From: line (DEP-3). git format-patch output satisfies this as written. |
source.patches.status | true | Every patch in the series records where it stands upstream: Forwarded: (a URL, no or not-needed), Applied-Upstream:, Bug:, or an Origin: that begins upstream or backport. |
[build] #
| Rule | Value | Checks |
|---|---|---|
build.dependencies.providers | array of provider names | Every build and test target declares a [build.<name>.dependencies.<provider>] (or [test.<name>.dependencies.<provider>]) table for each listed provider. An empty table is an explicit "none". |
build.test | true | The recipe defines a test target. A delegate recipe whose targets live in its source is not checked. |
[payload] #
All payload rules. Paths are payload destinations, slash-separated, relative to the package root.
Claim metadata uses canonical absolute paths in the installed logical root,
such as /usr/bin/sh. Staged and final-archive lint convert these to
package-relative keys when indexing claim slots; the leading slash does not
refer to the build host. Relative, root-only and noncanonical claim paths
(including .. components) are not valid claim-slot exemptions.
| Rule | Value | Checks |
|---|---|---|
payload.junk | true or an array of patterns | No destination matches a build-leftover pattern. true uses the built-in list: **/*.la, **/*.orig, **/*.rej, **/*.o, **/*~, **/*.swp, **/.git, **/.git/**, **/.gitignore, **/CMakeCache.txt, **/.DS_Store, usr/share/info/dir, **/perllocal.pod, **/.packlist. An array replaces it; "@default" in the array includes it. A .pyc without its .py is always reported. |
payload.scripts | true | A file with a #! line is executable; its interpreter is an absolute path that is shipped by this recipe or listed in payload.interpreters (for #!/usr/bin/env prog, both env's own path and prog must be allowed); and, for sh, dash, ash and bash, the host shell's -n syntax check passes. |
payload.symlinks.dangling | "forbidden" | Every symlink resolves to something shipped by one of the recipe's packages. Claim slot paths, which point across packages by design, are skipped. |
payload.symlinks.absolute | "forbidden" | No symlink has an absolute target. |
payload.filenames | "portable" | Every destination is printable ASCII with no whitespace. |
payload.special_files | "forbidden" | No device node, fifo or socket. |
payload.manpages.required | true | Every file in a bin directory has a manual page, shipped by any package of the recipe, named after it in any section. |
payload.manpages.compression | "gzip" or "none" | Every file under a man directory is gzip-compressed, or none is. |
payload.pkgconfig | true | No .pc file contains the build directory path. |
payload.license_file | a path template | Every package ships a regular file under the rendered directory. {{name}} is the package name; version tokens render as in a recipe. |
Parameters, all optional: payload.interpreters (default /bin/sh,
/usr/bin/sh, /bin/bash, /usr/bin/bash, /usr/bin/env,
/usr/bin/python3, /usr/bin/perl), payload.dirs.bin (default bin,
sbin, usr/bin, usr/sbin), payload.dirs.lib (default lib, lib64,
usr/lib, usr/lib64, lib/*-linux-*, usr/lib/*-linux-*) and
payload.dirs.man (default usr/share/man, share/man). Directory
parameters are glob patterns matched against a destination's directory.
[split] #
| Rule | Value | Checks |
|---|---|---|
split.devel.packages | a name pattern, or an array | payload. Development files land only in packages whose names match: headers, pkg-config files, CMake files, aclocal macros (the split.devel.files patterns, default usr/include/**, include/**, **/pkgconfig/*.pc, **/cmake/**, usr/share/aclocal/**, usr/lib/**/*.cmake) and unversioned .so symlinks in a lib directory. After ten findings in one package the rest are counted. |
Empty directories owned by a filesystem skeleton are not development files. The split check applies to the files and symlinks that provide the interface.
[elf] #
All payload rules, checked for every ELF program or shared library in
every package. Relocatable objects, kernel modules and anything under
usr/lib/debug/ or usr/src/ are skipped.
| Rule | Value | Checks |
|---|---|---|
elf.pie | true | No ET_EXEC program; executables are position-independent so address-space randomisation applies. |
elf.stack | "non-exec" | A PT_GNU_STACK header is present and does not request an executable stack. |
elf.relro | "partial" or "full" | A dynamic object has a PT_GNU_RELRO segment; with "full", it is also bound at load (BIND_NOW), so the GOT is read-only afterwards. |
elf.relr | true | A dynamic object with relative relocations left in .rela.dyn has none, or carries DT_RELR; the linker flag is -z pack-relative-relocs. |
elf.cet | true | An x86 object carries both IBT and SHSTK GNU properties, and inspected indirect-call entry points begin with ENDBR32/ENDBR64. Requires local symbols or matching build-ID debuginfo. Other architectures are not checked. |
elf.rpath | "forbidden" | No DT_RPATH or DT_RUNPATH. |
elf.textrel | "forbidden" | No text relocations. |
elf.stripped | true | No .debug_* sections and no .symtab. |
elf.debuginfo | a package-name template | The recipe has a package named by the template ({{name}} is the shipping package) and, for every object with a build-id, that package ships usr/lib/debug/.build-id/xx/yyyy.debug. An object without a build-id is reported. |
elf.build_paths | "forbidden" | The object does not embed the build directory path anywhere in its bytes. |
elf.soname | true | A lib*.so* regular file directly in a lib directory has a DT_SONAME, and its file name is the soname or the soname followed by more version components. |
What lint does not check #
Whether a package's role is documented, whether its split is sensible, whether a service runs under the right identity, and whether its files sit in the places a particular distribution mandates. The first three are review judgement; the last is pack-time validation in the package format, not a lint rule.
What CET validation establishes #
elf.cet checks the runtime payload's machine code, using exported function
symbols, the main callback passed to libc, and relocated pointers to known
functions in loaded data. It supports x86 REL, RELA and packed RELR relocations.
For stripped objects it obtains symbols from a debug file shipped by this
recipe at usr/lib/debug/.build-id/xx/yyyy.debug, and verifies the build ID,
machine and ELF class before using its addresses. The instruction bytes always
come from the runtime payload. Missing symbols, unreadable evidence and objects
with no supported targets produce findings rather than a reduced-coverage pass.
Functions reached only by direct calls, jump-table labels and the process entry point do not automatically require ENDBR. This check is bounded static evidence: it cannot enumerate computed or generated targets, prove shadow-stack compatibility, validate all inline assembly, or establish that a running system actually enables CET. Retain upstream tests and runtime enforcement tests where these behaviors matter.
Compile the code with the relevant protection option, including linked runtime
and assembly inputs. GNU ld's -z ibt and -z shstk force compatibility markers;
they cannot substitute for instrumentation. -z ibtplt generates IBT PLT entries
without overriding the input-note merge. On a native toolchain that provides
marked inputs, use -z cet-report=error to reject missing properties during
linking. Do not combine that check with forced properties: forcing suppresses
the corresponding missing-property diagnostic. See the
GNU linker options.
Rust uses its own control-flow protection code-generation option.
Its linked standard library must also be compatible. Older dependencies may
probe unstable features in build scripts: enabling RUSTC_BOOTSTRAP globally
can change those probes. A recipe that requires an unstable code-generation
option should confine that access to actual compiler code generation and test
the resulting executable, as well as the complete linked input set.
If a supported upstream/toolchain combination cannot meet the rule, document a
narrow allow_files."elf.cet" exception with the affected payload path, reason
and removal condition. Keep the resulting lack of compatibility visible; do
not manufacture a note to obtain a passing lint result.