This page is about bundling a program. To ship a library as interface + prebuilt binaries, see 12 - Distributing a Prebuilt Library.
Which one
mcpp packdoes is decided by the target'skind, not by a flag:mcpp pack <name>packs[targets.<name>], and abinbecomes a bundle while alib/sharedbecomes a library package. With no name, mcpp picks the only packable target.
A default dynamically linked binary produced by
mcpp buildhas a loader and RUNPATH tied to the build sandbox. It is a development artifact, not a deliverable. Three routes turn it into one — and none of them uses the host's C library.
Every route below produces an artifact whose C runtime comes from the
ecosystem, never from /lib64. That is deliberate: mcpp builds against a
private glibc precisely so a binary's behaviour does not depend on which
distribution happens to be underneath it, and reaching back out to the host's
libc to distribute would give that away at the last step.
| Route | Command | Where its C runtime comes from | Choose it when | |
|---|---|---|---|---|
| A | Through the ecosystem | mcpp emit xpkg → xlings install <pkg> |
the target machine's own xlings payloads | the target has xlings |
| B | One static file | mcpp build --target x86_64-linux-musl |
nowhere — it is linked in | a single file with no runtime dependency |
| C | Carry the runtime | mcpp pack --mode self-contained |
shipped inside the bundle | any Linux, including older than the build machine |
On route A. The PT_INTERP recorded in a freshly built binary points at the
build machine's payload, so copying that file to another machine by hand does not
work: the path does not exist there. This is a property of the copy rather than of
the artifact — installed through xlings, the package's ELF files are repointed at
the target machine's own payloads at install time. The recorded path is a
build-machine detail, not a distribution format. Routes B and C are the ones that
survive hand-copying.
On route B. --target …-musl implies a static link, so there is no loader,
no RUNPATH and nothing to find at run time. It is the smallest and most
portable result, and the one to reach for first when the program does not need
glibc-specific behaviour (NSS lookups, dlopen of host plugins).
On route C. The bundle carries this toolchain's glibc and its loader, so it
runs on distributions older than the build machine — the case B cannot cover
when glibc is actually required. Read the /proc/self/exe note below before
choosing it: launching through a bundled loader changes what the program sees
about itself.
Distribution is two orthogonal choices:
- libc / static — a build-target property:
--target …-linux-gnu(glibc) vs--target …-linux-musl(musl, static).--target …-muslimpliesstatic. - bundling depth — a pack property: how much of the shared-lib closure
travels with the artifact. This is what
--modeselects.
| Mode | Host must provide | Size | Use case |
|---|---|---|---|
system |
every .so (incl. third-party) |
smallest | .deb/.rpm, same-distro fleet (pkg manager declares deps) |
vendored (default) |
libc / libstdc++ / loader | +a few MB | Mainstream distros (Ubuntu 22+, Debian 12+, RHEL 9+) |
self-contained |
nothing | +30–50 MB | Any Linux incl. older glibc; bundles closure + run.sh wrapper |
static |
nothing (single file) | +5–10 MB | musl; matching Linux x86_64 or aarch64 host, Docker scratch, Alpine |
How to choose:
- Distro packages (
.deb/.rpm) or same-distro internal deploy →system - Desktop / server releases for mainstream distros →
vendored(default) - Cross-distro / older glibc (legacy CentOS, Kylin) →
self-contained - Single portable file, no host deps →
static
No mode ships a build-machine path. A development build addresses this
machine on purpose: its DT_RPATH names the toolchain's payload directories and
the SubOS library view (<subos>/lib), and its PT_INTERP names a private
loader. Every mode rewrites both — vendored/self-contained to $ORIGIN
relative paths, system by clearing the search path entirely and restoring the
platform's standard interpreter. system is not "keep whatever the build had";
it is "the target provides everything", which is a statement about the target
and cannot be spelled with this machine's absolute paths. e2e 215 sweeps every
ELF in the bundle for anything under $MCPP_HOME and fails on a hit.
"Self-contained" has a floor. Some libraries can only come from the target
machine: a graphics driver's user-space half is version-locked to the running
kernel module, and for the proprietary stacks redistribution is not permitted.
Declare those as run-phase capability requirements (§2.11 of
docs/05-mcpp-toml.md), and the mode table gains a column:
| Mode | Program needing a host-provided capability |
|---|---|
system |
✅ |
vendored (default) |
✅ the right default for these |
self-contained |
❌ refused at pack time |
static |
❌ refused at pack time |
The two refusals are the same fact: a bundle that carries its own libc cannot
consume a library the host supplies. That .so arrives with its own
requirements on the target's libc, and the process does not have that libc —
measured in both directions as mcpp#392 / mcpp#401, where a private glibc
meeting host-loaded objects dies during relocation, before main. Previously
both modes linked and then failed at startup, or silently degraded (for
graphics: software rendering, with nothing saying so).
vendored packages such a program and writes a HOST-REQUIREMENTS file at
the bundle root stating what the target must supply:
capability=opengl.glx.driver discovery=rpath-of-dispatch
discovery is the actionable half — the mechanisms are independent, so
satisfying one does not satisfy another. It is written only when there is
something to say: an empty file would be a claim that nothing is needed.
Canonical names are shown above. The old names remain permanent aliases:
bundle-project = vendored, bundle-all = self-contained. Tarball-name
suffixes are a frozen wire format (consumed by install.sh) and do not
follow the rename: vendored → no suffix, self-contained → -bundle-all,
static → -static, system → -system.
mcpp pack # vendored by default
mcpp pack --mode system
mcpp pack --mode static
mcpp pack --mode self-contained # alias: --mode bundle-all
mcpp pack --target x86_64-linux-musl # equivalent to --mode static
mcpp pack --target aarch64-linux-musl # ARM64 equivalent
mcpp pack --format dir # output as a directory, no tarball
mcpp pack -o myapp.tar.gz # filename only: lands at target/dist/myapp.tar.gz
mcpp pack -o /abs/path/myapp.tar.gz # includes a directory: output to the literal path
mcpp pack --profile dev # build with a different profile (default: release)
mcpp pack --no-strip # ship the artifacts as built
mcpp pack --debug-symbols dbg/ # write the separated *.debug files under dbg/When -o is given a bare filename, the output is placed under target/dist/;
when it includes a directory (relative or absolute), the literal path is used.
For the full set of options, see mcpp pack --help.
Two things differ from mcpp build, and both exist because a package leaves
this machine:
The profile falls back to release, not dev. Precedence is unchanged
otherwise — --profile beats [build] default-profile, which beats the
fallback. Only the last step differs, so a project that states a profile still
gets the one it stated, and mcpp pack never produces an artifact built with
flags mcpp build would not.
One consequence to know: a bare
mcpp buildand a baremcpp packnow write into differenttarget/<triple>/<fingerprint>/directories, because the fingerprint covers the profile. A file placed beside a built artifact by hand — a DLL, a data blob — is therefore only visible topackwhen both commands resolve to the same profile: state it in[build] default-profile, or pass--profileto both. The declarative channels ([runtime] deploy_files,runtime_search_dirs) are unaffected.
Debug information is stripped, and the publisher's paths go with it. An unstripped artifact carries DWARF, and DWARF carries the absolute paths of the producer's source tree and build directory. What is removed depends on what the artifact is — this is dh_strip's division, and the archive row is the one that matters:
| artifact | strip flags | why not more |
|---|---|---|
| executable | --strip-all |
nothing links against it |
| shared library | --strip-unneeded |
keeps .dynsym — that IS the export list |
| static archive | --strip-debug --enable-deterministic-archives |
--strip-all removes the archive symbol index, and the consumer's link then fails with archive has no index; run ranlib to add one |
All three also drop .comment and .note. Section removal is by exact name, so
.note.gnu.build-id survives and still pairs with --add-gnu-debuglink.
--no-strip (or [pack] strip = false) ships the artifacts exactly as built.
--debug-symbols <dir> separates the information instead of discarding it:
<dir>/<artifact>.debug is written and the shipped artifact gets a
.gnu_debuglink pointing at it, which is what a debugger and debuginfod
follow.
[pack] stripis not[profile.<name>].strip. The profile key appends-sto the link, which never touches a static archive and cannot separate anything; this one governs what the package carries. Two different decisions, two different names.
Bundled libraries are never stripped. They came out of the store or off the host, mcpp did not build them, and rewriting somebody else's shared payload for this bundle's benefit is not the packer's business.
The tarball contents are wrapped in a single top-level directory whose name
matches the tarball filename (minus the .tar.gz) —— this way both a GUI
"right-click extract" and a command-line tar -xzf yield the same
self-contained directory, instead of scattering the contents across the current
path.
target/dist/myapp-0.1.0-x86_64-linux-musl-static.tar.gz
└── myapp-0.1.0-x86_64-linux-musl-static/
├── bin/myapp ← fully static ELF (no PT_INTERP / RUNPATH)
├── myapp ← top-level entry point (thin shell wrapper, run ./myapp directly)
├── README.md ← copied automatically from the project root
└── LICENSE
target/dist/myapp-0.1.0-x86_64-linux-gnu.tar.gz
└── myapp-0.1.0-x86_64-linux-gnu/
├── bin/myapp ← dynamically linked, RUNPATH=$ORIGIN/../lib
│ PT_INTERP=/lib64/ld-linux-x86-64.so.2
├── lib/
│ ├── libcurl.so.4 ← project third-party dependency
│ ├── libssl.so.3
│ └── ...
├── myapp ← top-level entry point
├── README.md
└── LICENSE
The skip list follows
PEP 600 / manylinux2014 ——
base libraries such as libc, libm, libstdc++, libgcc_s, and
ld-linux-* are assumed to already exist on the target system and are not
bundled into the tarball.
target/dist/myapp-0.1.0-x86_64-linux-gnu-bundle-all.tar.gz
└── myapp-0.1.0-x86_64-linux-gnu-bundle-all/
├── bin/myapp
├── lib/
│ ├── ld-linux-x86-64.so.2 ← complete loader and libc
│ ├── libc.so.6
│ ├── libstdc++.so.6
│ ├── libgcc_s.so.1
│ └── ...project dependencies
├── myapp ← one of two entry points
├── run.sh ← the other entry point (identical contents)
├── README.md
└── LICENSE
With -o foo.tar.gz, the top-level directory name also becomes foo (the
package name and directory name always stay in sync).
The ELF specification forbids PT_INTERP from using $ORIGIN, so in
self-contained mode the loader is invoked by absolute path through run.sh (and
the top-level wrapper of the same name):
exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myapp" "$@"The layout and wrapper above use an x86_64 example. The packer derives the
loader name from the target; for aarch64 it is ld-linux-aarch64.so.1.
Being started by the loader has a consequence the layout above does not
show: the kernel sets /proc/self/exe to the loader, not to the program,
and /proc/self/cmdline carries the --library-path argument. Every "find my
resources next to the executable" path therefore resolves against lib/
instead of the bundle root — and it does so silently. In practice that means
a GUI toolkit rendering blank text because it cannot find its fonts, an
assets/ directory that appears to be missing, and helper binaries shipped
alongside the program that cannot be located. Code that parses argv from
/proc/self/cmdline sees the loader's arguments mixed in.
This affects self-contained only. vendored, system and static all
carry a PT_INTERP that the kernel can use directly, so /proc/self/exe is
correct there.
The wrapper exports MCPP_BUNDLE_DIR (the bundle root) for this. Resolve
against it first and fall back only when it is unset:
const char *base = getenv("MCPP_BUNDLE_DIR"); /* set by run.sh */
if (!base) {
/* not launched through the wrapper — /proc/self/exe is trustworthy */
}If the application cannot be changed — a third-party GUI framework doing its
own resolution, say — use --mode vendored instead. It repoints PT_INTERP
at the host loader, at the cost of requiring the host's glibc to be at least
as new as the one the artifact was built against.
A Windows target produces a .zip, not a .tar.gz, and the layout is
flat:
target/dist/myapp-0.1.0-x86_64-pc-windows-msvc.zip
└── myapp-0.1.0-x86_64-pc-windows-msvc/
├── myapp.exe
├── vcruntime140.dll ← only under cxx_runtime = "toolchain-coupled"
├── mydep.dll ← third-party dependencies
├── README.md
└── LICENSE
There is no bin/ + lib/ split and no entry-point wrapper, and neither is a
style choice. The Win32 loader resolves a DLL from the directory of the
executable; PE has no RUNPATH to point anywhere else, so "next to the
.exe" is the mechanism that $ORIGIN/../lib provides on ELF.
Windows' own DLLs are never bundled — kernel32.dll, ntdll.dll,
ucrtbase.dll, the api-ms-win-* API sets. Shipping a private copy of an OS
component is a broken program rather than a heavier one (the process ends up
with two of something that must be unique), and Microsoft's redistribution
terms say the same thing from the other side. [pack.bundle-project] force_bundle still overrides this, as it does the ELF skip list.
vcruntime140.dll and msvcp140.dll are not Windows' own: they belong to
the MSVC toolset, exactly as libstdc++.so belongs to gcc. Whether they
travel is decided by cxx_runtime (see docs/05-mcpp-toml.md), not by this
list — and mcpp pack refuses a combination that cannot deliver what the
contract promised:
$ mcpp pack --mode system # with cxx_runtime = "toolchain-coupled"
error: cxx_runtime = "toolchain-coupled" and --mode system contradict each other.
This works, and it is not a special mode — just build for a Windows target and pack:
mcpp pack --target x86_64-windows-gnu # from a Linux hostmcpp pack used to refuse Windows outright. The reason was not the archiver:
the ELF dependency closure is obtained by running the artifact under
LD_TRACE_LOADED_OBJECTS, which cannot cross an OS or an architecture. A PE
closure is read out of the file's import table instead, so nothing has to be
executed and the packaging host is free. The archive is written by mcpp itself
for the same reason — there is no zip tool present on every host.
Two consequences worth knowing:
- Entries are stored, not deflated, so a Windows package is roughly the size of its contents. Compression is a size optimization, not a correctness one, and it is not implemented yet.
- The archive is deterministic: no timestamps are read, so two packs of the same tree are byte-identical and a published checksum means something.
The reverse direction — packing a Linux or macOS artifact from Windows — still does not work, and for the original reason: that closure is resolved by the target's own dynamic linker, which a Windows host has no way to run.
The same closure step asks the dynamic linker for the dependency list by running
the artifact with LD_TRACE_LOADED_OBJECTS=1. That variable is glibc's; dyld
has never heard of it. So on a Mac the command does not trace anything — it
runs the program, and whatever the program prints is then parsed as a
dependency table. mcpp refuses instead, and says which mechanism is missing.
The refusal is keyed on the artifact's format, not on the host, for the same
reason the Windows one is: LD_TRACE_LOADED_OBJECTS cannot trace a Mach-O from
Linux either.
A kind = "lib" / "shared" target packs normally on macOS — a library package
never runs the artifact. This restriction is only for programs.
Packaging behavior is configured via the [pack] section in mcpp.toml. The
common fields are:
[pack]
default_mode = "static" # override the normal vendored default for bare `mcpp pack`
strip = true # default. false ships the artifacts as built
debug_symbols = "dist/debug" # separate the debug info here instead of discarding it
include = ["share/**", "config/*.toml"] # extra files to bundle
exclude = ["debug/**"]
# Fine-tune the vendored filtering policy. The configuration key keeps its
# established `bundle-project` spelling.
[pack.bundle-project]
also_skip = ["libcustom.so"] # libraries assumed to exist on the target system
force_bundle = ["libfoo.so"] # bundle even if matched by the PEP 600 list[pack].default_mode currently accepts the established manifest spellings
static, bundle-project, and bundle-all; the system mode is selected
explicitly with mcpp pack --mode system. CLI input accepts both the canonical
and compatibility names described above.
The static mode additionally requires a musl toolchain configured under
[target.<triple>]; for the full setup, see the mcpp.toml in
examples/03-pack-static.
macOS program bundling (the Mach-O dependency closure, via otool -L /
LC_LOAD_DYLIB, and install_name_tool for relocation) is still on the
roadmap; until it lands mcpp pack <program> refuses on that format rather than
producing something that only looks like a bundle. Windows DLL bundling beyond
the current .zip, and distribution formats such as .deb / .rpm / AppImage,
are also on the roadmap. This document evolves alongside the
mcpp pack implementation; for the latest options, refer to
mcpp pack --help.