# Running pekit

---

# Commands and targets

_pekit / Running pekit_

> The commands, what each selects and its version behaviour, target naming and needs ordering, and the gen/verify source-generation pair.

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](/pekit/running/invocation.md). For version selectors
(`--version`, `--latest`, `--all-versions`), see [Versions](/pekit/recipes/versions.md).

## The commands

| Command | Selects | Versions | `--all` | Side effects |
|---|---|---|---|---|
| `build` | `build` targets + their `needs` | multiple | no | Runs build targets; stages their output under `out_dir`. |
| `test` | `test` targets + the builds they need | **single resolved** | no | Stages needed builds, then runs the selected test targets. |
| `install` | `install` targets + the builds they need | **single resolved** | no | Stages needed builds, then runs the selected install targets. |
| `package` | package members + the builds they list | multiple | yes | Stages builds and writes `.peipkg` artifacts under `out_dir`. Recipes with a reproducible source also emit a [corresponding-source package](/pekit/recipes/sources.md#source-packages). |
| `publish` | package members (as `package`) | multiple | yes | Packages, then publishes to a configured `localdir` destination. |
| `clean` | one optional `clean` target | none | no | Runs a clean target and/or removes the managed output directory. |
| `gen` | `gen` targets | none | yes | Runs gen commands; writes generated source **into the tree**. |
| `verify` | `gen` targets | none | yes | Runs gen `verify_command`s; a read-only drift check (writes nothing). |
| `lock` | the recipe's source | multiple | no | Fetches and pins the selected versions in [`pekit.lock`](/pekit/recipes/sources.md#the-lockfile) without building. With no version flags, reports the current lock instead. |
| `workspace` | a delegated command across members | (delegated) | (delegated) | Runs one of the above across every workspace member. |

`gen` and `verify` are the source-generating pair — see
[Generating source](#generating-source-gen-and-verify) 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](/pekit/running/workspaces.md).

### 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:

```text
invalid_version_selector: test requires a single resolved version
```

`clean`, `gen`, and `verify` never resolve versions or materialise a source
tree — they operate on the recipe directly (the output directory, or the
committed tree at the recipe root), so version-selection flags are not accepted
(see the capability matrix below).

### Command / capability matrix

Each command accepts a fixed set of flag groups. Passing a flag the command does
not support is an **error** up front (`unsupported_flag`), unless you pass
`--allow-unused`, which downgrades it to a suppressed warning. Broadly:
`build`, `test`, `install`, `package`, and `publish` share the version-selection,
local-source, `--no-build`, `--no-verify`, and `--refresh-source` groups; `--all`
is `package`/`publish`/`gen`/`verify` only; `--allow-unanchored` and `--allow-unsigned` are `publish`
only; `clean` takes only `--env`, `--keyring`, and its own mode flags
(`--output-only` / `--target-only`); `gen` and `verify` take only `--env`,
`--keyring`, and `--all`; `lock` takes version selection, `--refresh-source`,
and its own `--repin`. The full command-by-flag matrix is in the
[command-line reference](/pekit/reference/cli.md).

Global flags (`--dry-run`, `--quiet`, `--verbose`, `--json`, `--recipe`,
`--allow-unused`) are accepted by every command and are covered in the
[invocation reference](/pekit/running/invocation.md).

## 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](#generating-source-gen-and-verify).)

### Bare (`main`) targets

A bare section — with a `command` key directly on it — defines a single target
named `main`:

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

[test]
command = "make check"
```

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

### Named targets

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

```toml
[build.lib]
command = "make lib"

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

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

### Bare and named cannot mix

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

```text
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:

```bash
pekit build lib tools
```

```text
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:

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

## Build dependencies (`needs`)

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

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

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

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

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

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

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

## `clean`

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

| Invocation | Runs clean target | Removes output dir |
|---|:---:|:---:|
| `pekit clean` (default) | yes, if `clean.main` (or the named selector) exists | yes |
| `pekit clean --output-only` | no | yes |
| `pekit clean --target-only` | yes | no |

Details:

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

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

```text
invalid_selector: clean accepts at most one target selector
```

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

## `--all` for `package` and `publish`

A multi-package recipe has several package **members**. `package` and `publish`
select members the same way targets are selected — by naming them, with an
optional `:instance` suffix for [multi-package](/pekit/recipes/multi-package.md)
enum instances — with two conveniences:

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

`--all` is exclusive with explicit selectors:

```text
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.

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

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

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

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

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

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

### Pre-flight drift checking

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

```text
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-verify` skips **all** drift gates.
- `pekit build --no-verify=bindings,other` skips only the named gens (an unknown
  name is an error).
- On `package` / `publish`, `--no-verify` skips the same pre-flight — those
  commands gate conservatively against every build target, as above.

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

## Selector rules

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

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

  ```text
  invalid_selector: selector "-lib" starts with '-'; use -- before selector-like values
  ```

  ```bash
  pekit build -- -lib
  ```

- Target selectors must be canonical (`A-Z a-z 0-9 _ . + -`, no leading `-`, no
  `/` or `:`). Package selectors follow the same grammar but allow a single `:`
  to separate a member from an enum instance.

## See also

- [Invocation and flags](/pekit/running/invocation.md) — the grammar, flag value forms, and global flags.
- [Command-line reference](/pekit/reference/cli.md) — the full command-by-flag capability matrix.
- [Versions](/pekit/recipes/versions.md) — the version selectors these commands accept.
- [Workspaces](/pekit/running/workspaces.md) — running a command across every member.

---

# Invocation and flags

_pekit / Running pekit_

> The command-line grammar, flag value forms, the global flags and their exclusion rules, output renderers, exit codes, and how a recipe is located.

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](/pekit/running/commands-and-targets.md).

## Grammar

```text
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:

```console
$ 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](/pekit/running/workspaces.md).

### `--` stops flag parsing

A bare `--` ends flag parsing. Every token after it is captured verbatim and
treated as a positional selector:

```console
$ 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](/pekit/reference/cli.md); 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:

```console
$ pekit build --local              # --local with an empty value
$ pekit build --local=/src/mypkg   # --local with a value
$ pekit build --local /src/mypkg   # --local (empty) + /src/mypkg as a selector
```

`-V` is an alias for `--version` and behaves identically (it is a required-value
flag; it is *not* a version-print flag — pekit has no version banner and no
`--help`).

Per-key keyring values use a dotted-path form, `--keyring.<path>=<value>`, which
always requires the `=` and a non-empty path.

## Global flags

Seven flags are parsed in any position regardless of command: `--recipe`,
`--workspace`, `--allow-unused`, `--dry-run`, `--quiet`, `--verbose`, and
`--json`. Six are accepted by every command; the seventh, `--workspace`, is only
valid with the `workspace` command. Each is covered in its own section below,
and the [command-line reference](/pekit/reference/cli.md) 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](/pekit/running/commands-and-targets.md).

`--recipe <path|dir>` selects the recipe directly instead of searching upward
from the cwd (see [How a recipe is located](#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](/pekit/reference/cli.md).

## `--allow-unused`

Every command declares which flags it accepts. Passing a **recognised** flag that
the effective command does not support is normally an error
(`unsupported_flag`), for example version selection on `clean`, or `--all` on
`build`. `--allow-unused` turns each such case into a non-fatal notice: pekit
records it and emits an `unused_suppressed` event at the start of the run instead
of aborting.

`--allow-unused` only relaxes the "recognised-but-unused" check. It does **not**
excuse:

- unknown flags (`unknown_flag`) or unknown commands (`unknown_command`);
- malformed flag values — missing values, empty `=` values, or `=value` on a
  no-value flag;
- the mutual-exclusion rules above;
- bad selectors (a selector beginning with `-`, or more selectors than the
  command accepts).

This is what lets you fan a shared set of flags across a whole
[workspace](/pekit/running/workspaces.md) even when some members' commands ignore
some of the flags.

## `--dry-run`

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

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

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

## Output renderers

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

### Human (default)

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

### `--quiet`

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

### `--verbose`

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

### `--json`

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

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

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

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

## Exit codes

Pekit uses two exit codes only: `0` on success, `1` on any error — bad
invocation, missing recipe, failed build, and so on. There are no finer-grained
codes, and there is **no `--help` or `help` command**: this page,
[Commands and targets](/pekit/running/commands-and-targets.md), and the
[command-line reference](/pekit/reference/cli.md) are the manual.

## How a recipe is located

Pekit resolves exactly one recipe per (non-workspace) run:

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

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

### Remote recipe locators

A **remote locator** is a recipe reference that points at a git repository rather
than a local path. A value is treated as remote when it does *not* start with `.`
or `/` and it either begins with `github.com/`, contains `://`, ends with `.git`,
or contains a `//` subdirectory separator. Examples:

```console
$ 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](/pekit/running/commands-and-targets.md) — what each command does and selects.
- [Command-line reference](/pekit/reference/cli.md) — every flag, its value form, and the validation table.
- [Workspaces](/pekit/running/workspaces.md) — the workspace tail and its flag placement.
- [Sources](/pekit/recipes/sources.md) — what materialising a remote recipe's source involves.

---

# Workspaces

_pekit / Running pekit_

> The workspace.pekit.toml file, member discovery, the workspace command and its flag placement, --jobs and --fail-fast, and cross-member publish checks.

A **workspace** lets you drive a whole collection of recipes as one unit. Instead
of running `pekit build` in each recipe directory by hand, you declare which
recipes belong together in a `workspace.pekit.toml` file and run a single
`pekit workspace build`, which delegates that command to every member. This is
how a distro-scale tree of packages — dozens or hundreds of recipes — is built,
tested, packaged, and published in one go.

A workspace does three things:

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

## The `workspace.pekit.toml` file

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

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

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

[env]
SOURCE_DATE_EPOCH = "1700000000"

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

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

### Member discovery

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

Discovery proceeds as:

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

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

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

> [!NOTE]
> Members are recipe **directories**, identified by the presence of a
> `pekit.toml`. The workspace file itself is named `workspace.pekit.toml`; it is
> a different file from a recipe.

### Locating the workspace

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

## Member identity

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

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

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

## Invocation

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

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

### Strict flag placement

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

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

```console
# correct: workspace flags before the command, command flags after
$ pekit workspace --jobs 4 --fail-fast build --version 1.2.3

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

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

Because `--jobs`/`--fail-fast` are recognised *only* in the workspace slot,
writing them after the delegated command hands them to the delegated command's
own parser, which rejects them as an `unknown_flag`. Keep them in the workspace
slot.

Both failures share the diagnostic code `missing_workspace_command`: it fires
when a command flag precedes the delegated command, and when no delegated
command follows the workspace flags at all ("workspace requires a delegated
command").

## Execution model

Each member is run as an ordinary **per-recipe invocation** of the delegated
command — exactly as if you had `cd`'d into that member and run
`pekit <command> ...` there, with the delegated flags and any positional
selectors carried through unchanged.

### Concurrency: `--jobs`

`--jobs N` sets how many members run concurrently. `N` must be a **positive
integer**; a missing value is `missing_flag_value` and a non-positive or
non-numeric value is `invalid_flag_value` ("--jobs requires a positive
integer"). The **default is 1**, i.e. members run one at a time in id order.

### Fail-fast: `--fail-fast`

By default the workspace attempts **every** member even if some fail: it runs
them all and then fails the overall command at the end if any member failed
(`workspace_failed`, "N workspace member(s) failed").

With `--fail-fast`, the first member failure sets a stop flag so that **no
further members are started**. Members that had not yet begun are recorded as
**skipped**.

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

### No inter-member dependencies

Members are independent. Pekit does **not** compute a dependency graph between
members, does not topologically order them, and does not make one member's build
visible to another as an input. The only ordering is the lexicographic id order
used for dispatch. If a member needs another member's output, that relationship
must be expressed through the normal recipe machinery (sources, package
dependencies), not through workspace membership.

### Summary

At the end of a run pekit emits a `workspace_summary` event tallying members as
**succeeded**, **failed**, or **skipped**:

```text
12 succeeded, 1 failed, 3 skipped
```

*Succeeded* and *failed* count members that actually ran; *skipped* covers
members not started because of `--fail-fast`, and members skipped by selector
planning (below). The overall command exits non-zero if any member failed.

## Per-member strictness

The delegated command and its flags are validated once, when the tail is parsed.
Beyond that, strictness is evaluated **per member**, because members differ: one
recipe may define a `build` target or a package selector that another does not.

- Each member runs the delegated command against its own recipe. If a flag or
  selector cannot be honoured by that member's recipe, **that member errors** —
  its failure is reported (`member_failed`) and counted, and the other members
  are unaffected (unless `--fail-fast` is set).
- **`--allow-unused`** relaxes this per member. When a run carries positional
  selectors (target names, or `def:variant` package selectors) together with
  `--allow-unused`, pekit plans which selectors apply to which member:
  - selectors that a member cannot satisfy are **suppressed** for that member
    (reported via `workspace_selector_suppressed`);
  - a member to which **none** of the requested selectors apply is **skipped**
    ("no requested selectors apply to member");
  - if a requested selector applies to **no** member at all, the whole run
    fails with `missing_selector` ("workspace selector … matches no selected
    member").

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

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

## Cross-member publish collision detection

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

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

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

## Shared configuration

### `[env]` and `[wrap]`

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

### Policy: symbol-version floors

The `[policy]` table carries distro-wide derivation policy. Its one current
sub-table is `[policy.symbol_versions]`, a map from a shared-library **soname**
to the **symbol-version token prefix** whose tokens are commensurable with the
providing package's version:

```toml
[policy.symbol_versions]
"libc.so.6" = "GLIBC_"
"libstdc++.so.6" = "GLIBCXX_"
```

This governs which sonames receive a symbol-version floor when pekit derives a
package's dependencies across the workspace — so that, for example, a binary
using a `GLIBC_2.34` symbol gains an appropriate lower bound on its libc
dependency. An unknown sub-table under `[policy]` is rejected
(`unknown_key`, "policy.… is not a known policy table"). For how derived
dependencies and claims work, see
[Dependencies and claims](/pekit/recipes/dependencies-and-claims.md).

## Where to go next

For the recipes a workspace's members are, read [Anatomy of a recipe](/pekit/recipes/anatomy.md).

For how workspace `[env]` and `[wrap]` compose with a recipe's own, read [Environments and keyrings](/pekit/recipes/environments-and-keyrings.md).

For the full flag surface and remote locators, read [Invocation and flags](/pekit/running/invocation.md).

---

# Signing and provenance

_pekit / Running pekit_

> How pekit signs .peipkg artifacts and PIP-signs binaries, the provenance every manifest records, corresponding-source packages, and the gates publish enforces.

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`](/pekit/recipes/sources.md#the-lockfile) and upstream release
signatures can be verified against committed keys — both covered on the
[Sources](/pekit/recipes/sources.md) 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](/pekit/recipes/environments-and-keyrings.md), 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.

```console
$ pekit publish --keyring=dev
```

With a key configured, **every peipkg-format artifact the run produces** —
package members and the corresponding-source package alike — is packed with
an embedded signature naming the key's fingerprint (the lowercase hex SHA-256
of the raw 32-byte public key), and each emits a `sign` event. Sign events
stay visible under `--quiet`, so a quiet run still shows what was signed with
which key.

Two failure behaviours are deliberate:

- A configured key that cannot be loaded is a hard `signing_key` error. Pekit
  never falls back to silently writing unsigned output — a signing setup that
  degrades quietly would defeat the point.
- Without a key, `package` writes unsigned artifacts (fine for local
  development), but `publish` refuses peipkg-format packages with
  `unsigned_publish` unless you pass `--allow-unsigned`.

## Signing binaries for PIP

Package signing protects the artifact in transit; it says nothing to the
kernel about the programs inside. The Peios kernel derives a process's
[Process Integrity Protection](/peios/advanced-peios/pspk/binary-signing-and-pip/process-integrity-protection.md)
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:

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

[build.main.sign.pip]
"usr/bin/peinit"  = "tcb.priv"
"usr/lib/*.so.*"  = "tcb.priv"
```

```toml
# dev.keyring.pekit.toml — per-developer, gitignored
[tcb]
priv = "kacs-tcb-dev.key"     # openssl genpkey -algorithm ML-DSA-65
```

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

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

For each matched file, pekit:

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

Two properties are deliberate:

- **Every failure is fatal.** A keyring entry that is missing or does not
  load, a pattern that matches nothing, a file that is not a 64-bit
  little-endian ELF, a reserved section of the wrong size, and two patterns
  naming different keys for one file all abort the target. There is no
  `--allow-unsigned` equivalent, because an unsigned binary is not a weaker
  binary: it runs with no tier and no diagnostic, and build time is the only
  place the mistake is visible.
- **Only the ELF section form is produced.** The specification also allows
  the signature in an extended attribute, but package payloads
  [carry no extended attributes](/peios/advanced-peios/pspu/package-format-and-repository-protocol/determinism.md),
  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](/pekit/recipes/sources.md#source-roots-and-provenance): `git:<url>@<commit>`, `url:<url>#sha256:<hash>`, and so on. |
| `recipe_ref` | The recipe tree's own git commit, `git:<commit>`. Suffixed `+dirty` when the work tree has uncommitted changes anywhere — including a freshly written, not-yet-committed `pekit.lock`. Omitted when the recipe is not inside a git work tree. |
| `builder` | The producing pekit's own revision: `pekit/<12-hex-commit>`, `+dirty` when built from a modified tree, falling back to the module version when the binary carries no VCS stamp. |
| `source_package` | The name of the corresponding-source package emitted from this recipe (below); empty when none is. |

Together these state the full provenance chain — the exact upstream inputs,
the exact recipe that drove the build, and the exact tool that performed
it. `recipe_ref` deliberately resolves the **enclosing** repository: a
workspace member's identity is the workspace repository's commit, not some
per-recipe notion. A dirty tree is over-marked rather than under-marked,
because a commit id alone does not describe a build whose tree had local
changes.

## Corresponding-source packages

A recipe with a reproducible source automatically emits a
**corresponding-source package** whenever it packages `peipkg`-format
members. This is how the distribution meets copyleft source-availability
obligations: publishing a binary and its source package through the same
channel keeps the exact inputs of every published build available for as long
as the binary is.

One source package covers every peipkg member the recipe produces. It is
emitted when all of these hold:

- `source_package.enabled` is not set to `false` (emission is the default);
- the source actually materialised as **git with a resolved commit** or
  **url** — a local override never qualifies, and neither does a git source
  built from a bare branch ref with no selected version (a deliberately
  moving target has no stable corresponding source);
- at least one emitted member has `format = "peipkg"`.

The package is named `<recipe-dir>-source` by default (rename with
`source_package.name`), is `noarch`, and is versioned identically to the
members — members that disagree on version are a
`source_package_version_conflict` error. Its license is the conjunction of
the members' distinct licenses, joined with ` AND `; its homepage is kept
only when every member agrees on one; and it publishes to the first member's
[`[publish]`](/pekit/reference/supporting-files.md#publish-targets) destinations.

Everything installs under `/usr/src/dist/<name>-<version>/` (with any
`-source` suffix stripped from `<name>`):

- `upstream/` — the pristine source input. For a url source, the downloaded
  artifact byte-for-byte, so its hash matches the committed `pekit.lock`; for
  a git source, a `git archive` export of the locked commit — deterministic
  for a commit and independent of the mutable checkout.
- `patches/` — the recipe's [patch series](/pekit/recipes/sources.md#patches),
  when one is declared. The applied series is the shipped series by
  construction, and it ships under the fixed `patches/` name even when
  `[source].patches` picks a different directory.
- `recipe/` — the build-controlling files from the recipe directory:
  `pekit.toml`, `pekit.lock`, package definitions (including
  `packages.pekit/`), env files, and `keys/`. `*.keyring.pekit.toml` files
  are **never** included — developer key material stays out.

Each binary member's manifest names the source package in
`build.source_package`, linking every binary to its corresponding source. The
recipe-side schema is in the
[recipe format reference](/pekit/reference/recipe-format.md#source_package).

## What publish checks before shipping

`publish` is packaging plus shipping, and the shipping half is gated:

- **Unsigned peipkg packages are refused** (`unsigned_publish`) unless
  `--allow-unsigned` is passed, as above.
- **Unanchored provenance is refused** (`unanchored_provenance`) unless
  `--allow-unanchored` is passed. Unanchored means a url source with neither
  a `checksum` nor a lock entry — a state a real resolve immediately locks
  away, so in practice this gates dry-run publish plans for never-fetched
  sources. A plain `package` from an unanchored source warns instead.
- **An existing destination file is refused** (`publish_exists`) when the
  target's `overwrite = false`.
- **Two artifacts resolving to the same destination** in one run is a
  `publish_collision`; across workspace members, the
  [publish preflight](/pekit/running/workspaces.md#cross-member-publish-collision-detection)
  catches the same collision before any member ships anything.

## Where to go next

For the input side of the chain — the lockfile, `--repin`, and upstream
signature pinning — read [Sources](/pekit/recipes/sources.md).

For the keyring files that carry the signing key, read
[Environments and keyrings](/pekit/recipes/environments-and-keyrings.md).

For every flag mentioned here, read the
[command-line reference](/pekit/reference/cli.md).
