Skip to content

Latest commit

 

History

History
418 lines (335 loc) · 19.8 KB

File metadata and controls

418 lines (335 loc) · 19.8 KB

02 — Packaging an Application for Release

This page is about bundling a program. To ship a library as interface + prebuilt binaries, see 12 - Distributing a Prebuilt Library.

Which one mcpp pack does is decided by the target's kind, not by a flag: mcpp pack <name> packs [targets.<name>], and a bin becomes a bundle while a lib/shared becomes a library package. With no name, mcpp picks the only packable target.

A default dynamically linked binary produced by mcpp build has 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.

Three distribution routes

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

Two axes: target (libc) × mode (bundling depth)

Distribution is two orthogonal choices:

  • libc / static — a build-target property: --target …-linux-gnu (glibc) vs --target …-linux-musl (musl, static). --target …-musl implies static.
  • bundling depth — a pack property: how much of the shared-lib closure travels with the artifact. This is what --mode selects.
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.

A program that needs the HOST to provide something

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

Mode name compatibility

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.

Commands

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.

What a packed artifact is built with, and what travels inside it

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 build and a bare mcpp pack now write into different target/<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 to pack when both commands resolve to the same profile: state it in [build] default-profile, or pass --profile to 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] strip is not [profile.<name>].strip. The profile key appends -s to 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.

Output Layout

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.

Mode static

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

Mode vendored (default; alias: bundle-project)

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.

Mode self-contained (alias: bundle-all)

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.

/proc/self/exe under the bundled loader

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.

Windows (PE): a .zip, with the DLLs beside the .exe

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

Packing a Windows program from Linux or macOS

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 host

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

Packing a Mach-O program is refused — on every host, including macOS

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.

Configuration

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.

Planned Support

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.