Build signed recipes into .cpkg archives and run their entrypoints in a Linux
sandbox. See the examples for .rhai recipe syntax and signing.
Recipes are Rhai scripts in *.rhai files that call
package(#{ ... }) once, with step(...) for each build step. Rhai has no
keyword arguments, so package takes an object map. Loops, functions and
conditionals generate steps and commands; evaluation is hermetic (no I/O, no
clock, no import or eval), runs under operation and size limits so a
runaway loop is an error rather than a hang, and happens before the policy is
derived and before anything runs.
package(#{
name: "hello",
version: "1.0.0",
steps: [
step(Install, "stage", ["install -Dm755 /usr/bin/echo /dest/usr/bin/hello"]),
],
});pm generate build.rhaiwrites a starter file.pm-lspis a language server (diagnostics, completion, hover) for.rhairecipes, andeditors/has Zed and Neovim integrations that use it.pm-lsp --definitionsprints a Rhai definitions file for editors that run a Rhai language server of their own.
step(...), kernel(...) and Package(name, version) return objects with
properties and methods, so a package can be built up before it is declared.
Recipes can also define their own objects as object maps with closures, where
this is the map:
let autotools = #{
prefix: "/usr",
build: |name| step(Build, name, [`./configure --prefix=${this.prefix}`, "make"]),
};
let p = Package("hello", "1.0.0");
p += autotools.build("compile");
p += step(Install, "stage", []).push("make install DESTDIR=/dest");
p.depends_on("../libc/build.rhai");
package(p);Step has stage, name, run and dl_urls, plus push(command),
download(url, sha256) and += command. Package has name, version,
dependencies, steps and kernel, plus depends_on, add_step,
add_steps, with_kernel, += step and for step in package. Setting a
property is checked on the spot, so s.stage = "Deploy" fails on that line.
An installed plugin is a Rhai module under its own name. Its symbols are
constants and, when it is built against the recipe-plugin world, its recipe
functions are functions that return pm's own types, checked as if the recipe
had built them:
let p = Package("units", "1");
p += systemd::install_unit("units/foo.service"); // a Step
p += step(Test, "unitdir", [`test -d /dest${systemd::unitdir}`]);
package(p);pm plugins lists what each plugin adds. See
plugins/README.md.
Recipes are compiled with Rhai's full optimiser (constant folding and inlining of pure calls) and fast operators, lowered to bytecode and run on Rhai's Grain VM. The engine is built once per thread, so a dependency graph of many recipes pays for it once. Strict variables and strict map properties catch misspellings before anything runs.
Some Rhai features are deliberately left out: floating point (no_float; a
package has no use for it and it makes evaluation platform-dependent), the
clock (no_time), import and eval (a signature covers one file as
written), the unchecked build (it removes the operation and size limits),
and an on-disk bytecode cache (the bytes that run would no longer be the bytes
that were signed).
Recipes used to be Starlark .package files, and before that YAML. Both still
load, so nothing breaks today, but each load logs a deprecation warning and
support will be removed in a future release.
pm migrate build.packagewritesbuild.rhainext to it, describing the same package.pm migrate build.yamldoes the same for YAML.-ralso converts every Starlark or YAML recipe it depends on and points the dependency paths at the new files;--stdoutprints instead of writing.- The Starlark is evaluated, not translated, so loops and functions come out as the steps they produced, written out literally. Comments are not carried over.
- A signature covers one file's bytes, so sign the new file with
pm sign. - The old file is left in place. Delete it, and its
.sig, once the.rhaifile is signed and builds.
A package can ship its own Linux kernel. Install the image into DESTDIR with
the package's steps and name it in package(#{ ... }):
package(#{
name: "hello",
version: "1.0.0",
steps: [
step(Install, "stage", [
"install -Dm644 /path/to/vmlinuz /dest/boot/vmlinuz",
"install -Dm755 /path/to/hello /dest/usr/bin/hello",
]),
],
kernel: kernel("boot/vmlinuz", "mitigations=off"),
});image is relative to DESTDIR. The build fails if the steps did not install
it or if it is not a Linux kernel image (a bzImage, a vmlinux, or an arm64 or
RISC-V Image), and the image is never offered as a program. cmdline is
optional and goes on the kernel command line ahead of the parameters pm relies
on (console, panic, rdinit), so it cannot override them; init= and
rdinit= are refused.
pm run boots such a package's kernel in a QEMU virtual machine instead of
running it in the namespace jail on the host's kernel:
- The guest's root filesystem is an initramfs pm assembles for each run. It
holds the package at
/pkg, the host loader and shared libraries the package's binaries link (at their host paths), andpm-vm-init, which runs the entrypoint, reports its exit code to the host and powers the machine off. Nothing else from the host is visible, the machine has no network device and no disk, and the recorded landlock profile is not applied inside it. - The console is your terminal, so the program's output and input work as they
do in the jail. Its exit code is
pm run's exit code. - It needs
qemu-system-x86_64onPATH, and uses KVM when/dev/kvmis usable (emulation otherwise, which is slow). Only x86-64 hosts can boot a package kernel today. - The kernel needs initramfs support, an 8250/16550 serial console and ELF
support built in, which distribution
genericandvirtualkernels have. --networkand--auditare refused, because the guest has no NIC and a traced host process cannot see into a VM.pm run --host-kernelruns the package in the usual jail and ignores its kernel.
Install pm-vm-init beside pm: it is copied into every initramfs, and is
much smaller than pm, which is used in its place when it is missing.
Projects such as dichhead/losos-desktop generate recipes and assemble system
images from their outputs. Use these interfaces instead of duplicating pm's
internal download hashing or parsing its human-readable policy table.
pm source-path 'https://example.org/project-1.0.tar.xz'
pm source-path --relative 'https://example.org/project-1.0.tar.xz'The first command prints the source's absolute path inside the build jail
(/build/.../project-1.0.tar.xz). The second prints a path relative to the build
working directory, useful for unconfined builds. Both print only one path to
stdout, perform no downloads, create no files and load no plugins. Use exactly
the URL in the recipe's dl_urls map, including its query string. URLs without a
usable filename fail with a nonzero exit status.
Rust consumers can use pm::step::Step::download_path(&url). The downloader uses
this same function; consumers should not depend on the digest algorithm. Resolve
paths with the pm version that will build the generated recipe. Hash verification
still happens during the build, not during a path query.
pm explain build.yaml --format yaml
pm explain build.yaml --format yaml --permissiveYAML output is a single document on stdout; diagnostics stay on stderr. It contains:
| Field | Meaning |
|---|---|
schema_version |
Currently 1; reject unsupported versions in consumers. |
name, version, dependencies |
Package name, version components and declared dependency recipe paths. |
fingerprint |
The same policy change-detection digest as the text report, not a cryptographic signature. |
capabilities |
Sorted capability names, including Network when granted. |
commands |
Ordered records with command, fingerprint and boolean matched. |
plugins |
Loaded plugin name, version, sha256 and trust. |
symbols |
Referenced plugin symbols mapped to their expanded values. |
downloads |
Records with step, url, expected sha256 and in-jail path, sorted by URL within each step. |
The report covers the specified recipe, not its dependency closure. It expands symbols and derives policy without running build commands or fetching sources. Signature verification and plugin trust rules are identical to text mode. Existing text output remains the default.
Always check the process exit status, even when stdout contains a valid report.
With --permissive, unmatched commands are included with matched: false, but
the command still exits nonzero. Without it, policy derivation fails before a
report is emitted. Missing/untrusted recipes and unusable plugins also fail.
Regular files are binary entrypoints only when at least one Unix executable bit
is set. Libraries (.a, .so, .so.N) remain library entrypoints without
executable bits. Non-executable systemd units, desktop entries, icons, headers
and configuration files remain in the archive but are not offered as programs.
pm is not yet a system package installer or image composer. Dependencies are
bundled as nested archives, not automatically merged into a build sysroot or
runtime root. An image builder must preserve the complete required payload
(including /etc and /var, not only /usr), handle file conflicts and symlinks,
and keep build-only pkg-config prefix rewrites out of the final image. Service
activation, sysusers, presets, architecture-aware image manifests and transactional
system updates remain the distribution's responsibility. These interfaces do not
make pm run a replacement for systemd service management.