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