(sigil build external)
(sigil build external) - Production external-build derivation kind.
Seal schema v1 is frozen by topics/sigil-seal-spec section 5. This module owns strict recipe decoding and the resolve/realize/fetch-output backend.
Exports
external-sourceprocedureConstruct a external-source struct.
external-source?procedureTest if a value is a external-source struct.
external-source-locationprocedureGet the location field of a external-source struct.
external-source-sha256procedureGet the sha256 field of a external-source struct.
%channel-patch--typevariableGet the sha256 field of a external-source struct.
channel-patchprocedureA patch that lives inside the signed channel tree. PATH is relative to the channel repository root and SHA256 is the patch file's content hash. The realizer reads the file from the verified tree and re-hashes it, so the patch is authenticated by the existing source-closure hash and adds no new trust surface (URL patches remain legal via external-source).
channel-patch?procedureTest if a value is a channel-patch struct.
channel-patch-pathprocedureGet the path field of a channel-patch struct.
channel-patch-sha256procedureGet the sha256 field of a channel-patch struct.
patch-input-sha256procedureThe content hash of either patch input form. Patch inputs are content-addressed regardless of where the bytes come from, so the derivation record and the store key never learn which form was used.
%external-phase--typevariableThe content hash of either patch input form. Patch inputs are content-addressed regardless of where the bytes come from, so the derivation record and the store key never learn which form was used.
external-phaseprocedureConstruct a external-phase struct.
external-phase?procedureTest if a value is a external-phase struct.
external-phase-kindprocedureGet the kind field of a external-phase struct.
external-phase-argsprocedureGet the args field of a external-phase struct.
external-phase-patchesprocedureGet the patches field of a external-phase struct.
%external-divergence--typevariableGet the patches field of a external-phase struct.
external-divergenceprocedureConstruct a external-divergence struct.
external-divergence?procedureTest if a value is a external-divergence struct.
external-divergence-file-globprocedureGet the file-glob field of a external-divergence struct.
Get the max-differing-bytes field of a external-divergence struct.
%external-dep--typevariableGet the max-differing-bytes field of a external-divergence struct.
external-depprocedureOne resolved dependency edge as the builder sees it: the NAME a phase argument may interpolate as ${name}, the realized OUTPUT tree hash, and the SEARCH-PATHS the dependency exports to its consumers.
All three are output-determining and none of them is recoverable from the output hash alone -- two packages differing only in search-paths: have byte-identical derivation records but hand their consumers different flags. That is why external-build schema-version is 2 and why named-deps is part of the record.
external-dep?procedureTest if a value is a external-dep struct.
external-dep-nameprocedureGet the name field of a external-dep struct.
external-dep-outputprocedureGet the output field of a external-dep struct.
external-dep-search-pathsprocedureGet the search-paths field of a external-dep struct.
%external-recipe--typevariableGet the search-paths field of a external-dep struct.
external-recipeprocedureConstruct a external-recipe struct.
external-recipe?procedureTest if a value is a external-recipe struct.
external-recipe-nameprocedureGet the name field of a external-recipe struct.
external-recipe-versionprocedureGet the version field of a external-recipe struct.
external-recipe-sourceprocedureGet the source field of a external-recipe struct.
external-recipe-buildenvprocedureGet the buildenv field of a external-recipe struct.
external-recipe-depsprocedureGet the deps field of a external-recipe struct.
external-recipe-dep-inputsprocedureGet the dep-inputs field of a external-recipe struct.
external-recipe-build-inputsprocedureGet the build-inputs field of a external-recipe struct.
external-recipe-envprocedureGet the env field of a external-recipe struct.
external-recipe-phasesprocedureGet the phases field of a external-recipe struct.
external-recipe-divergencesprocedureGet the divergences field of a external-recipe struct.
external-build-recordprocedureConstruct the external-build Seal record. DEPS contains every dependency and content-addressed patch input.
SCHEMA VERSION 3 (Stage 1, P1.2) does two things, and they pull in opposite directions on purpose.
IT REMOVES store-root:. The record used to carry the building machine's store path, which made every derivation key machine-local: the same package built from two different --store roots produced two different keys, so a build could never be shared and portability could not even be expressed. P1.1 made the removal possible by MOUNTING the store at a fixed /sigil/store inside the sandbox, so the path a build bakes into its outputs no longer depends on where the store lives. Removing the field before that would have been a lie in the other direction -- outputs still varying, the record claiming they did not.
IT ADDS sandbox:. P1.1 introduced a second builder mode, and that created a defect this bump closes: a sandboxed build and a degraded one had the SAME derivation key and DIFFERENT outputs. Measured 2026-07-31, and it is not a subtle difference -- zig cc with no explicit target bakes a host-specific /gnu/store/<hash>-glibc-2.41/lib/ld-linux-x86-64.so.2 interpreter into every executable it links, that loader is not a declared input, so inside the namespace GNU hello FAILS at AC_PROG_CC while the degraded build of the same package exits 0. One key, one success, one failure. §1.1 names same-key-different-output as the one thing a content-addressed store cannot survive.
A consequence worth knowing because it retires a documented hazard: a degraded build and a sandboxed build are now DIFFERENT derivations, so the store can no longer serve one for the other. That closes the sandbox=none stickiness P1.1 shipped with, where a degraded-first build marked an item and a later sandboxed request was a cache hit that never cleared the marking.
THE PHASE VOCABULARY IS NOW FINAL. A phase seals as (kind, args, patches) and that shape covers every kind in the vocabulary, run included, even though run does not ACTIVATE until P2.1. Defining it here is the point of §1.1's rule: later stages activate fields, they never redefine the schema, so identities stay stable as capability arrives. run is accepted by the decoder and refused at execution by name -- policy, not schema.
SCHEMA VERSION 2 (Stage 0, P0.3) adds named-deps. Version 1 recorded only the order-free SET of dependency store items, which was sufficient while dependencies reached a build exclusively through flags the builder derived identically for every package. Two things broke that:
${name}interpolation makes the NAME->item association part of what a phase command means.search-paths:lets a dependency export environment to consumers, and that export lives in the dependency's package object, NOT in its derivation record -- so two dependencies differing only in their exports have the same store item name and hash while handing consumers different flags.
Either would let two builds with different outputs share a derivation key. A content-addressed store cannot survive that, so the record carries the association and the exports, and the version moves.
STAGE 2 (P2.1/P2.2/P2.3) ADDS build-inputs AND env AND DOES NOT BUMP THE SCHEMA VERSION. The version stays 3. Read this before concluding that is an oversight.
The version exists to stop one thing: same key, different output. Two populations, and neither needs discriminating.
(a) A package declaring none of the three. Builder semantics are byte-identical before and after -- same env -i list, same PATH, same run-phases.sh -- and the RECORD is byte-identical too, because build-inputs and env are sealed ONLY when non-empty (below). So the key does not move and neither does the output.
(b) A package declaring any of them. No such derivation record can exist anywhere today: check-unrealizable-fields in (sigil channel publish) refuses build-inputs:, env: and any run phase at exit 70, on BOTH channel publish and channel check. Unpublished means no catalogue binding and no object, so the engine never reaches such a package; run additionally errored in phase-command. There is no store item and no key in existence whose meaning this change alters. MEASURED, not assumed -- test/integration/stage2-inertness-probe.sh runs a matched pair per field (control passes, each field refused by name), because "it is refused" is otherwise an absence claim that passes for free.
A bump would therefore discriminate the empty set while moving every existing package identity to do it. Compare P1.3, which changed the compiler for EVERY package -- genuinely output-determining across the board -- and still bought its way out structurally.
WHAT MAKES THE FIELDS KEYED IS STRUCTURAL, NOT A CONVENTION: the builder reads env and build-inputs from external-recipe-env and external-recipe-build-inputs, which are the same values sealed here. There is no path by which a build can honour an env value or a build-input that is not in the record.
SEALED ONLY WHEN NON-EMPTY, and the direction of that choice matters. The omitted value is the no-op value, so a record that omits env and one carrying an empty env denote the same build. Omission can only ever OVER-discriminate (two spellings of one thing getting two keys, which costs a redundant build) and never UNDER-discriminate, because a seal-map with a different key set has different bytes. Waste is the survivable failure; the other one is not.
BUT THE PARAMETERS ARE REQUIRED, exactly as target: is, and for P1.3's reason rather than for tidiness. If they defaulted, a call site nobody has written yet could omit them while the recipe carried them -- producing a record that does not rekey for a build that does differ. That record is the one that would serve a -fcommon artifact under a key that never mentioned it. A caller with nothing to declare passes the empty list, which is a claim, not a silence. The unit tests "a record cannot be built without an env declaration" / "...without a build-inputs declaration" are what that rests on; the sabotage that must never go green is giving either one a default.
named-deps->sealsprocedureSeal a whole parallel (inputs, store-items) pair as the record's name/item/exports list. named-deps and build-inputs are the same shape and were two verbatim-identical named-lets; one helper means named-dep->seal's argument order has ONE production call site rather than two that can drift apart.
external-version-satisfies?procedureThe single external-recipe constraint implementation. Exact versions and strict same-major caret constraints are accepted; a caret never admits a version lower than its requested floor.
external-resolveprocedureresolve(name, constraint) -> recipe from the selected channel.
interpolate-argumentprocedureExpand ${out} and ${dependency-name} in one phase argument.
This runs at ARGV level, before shell-quote, so an expansion can never introduce a shell metacharacter that the quoting layer then has to survive. An interpolation of a name that is not a declared input is an error here and a refusal at sigil channel publish, so a build cannot silently depend on something its definition does not name.
builder-base-assignmentsprocedureThe builder's own assignments, as (NAME . VALUE) pairs. THE single source of truth for what the builder owns.
builder-owned-namesprocedureThe names the builder owns, for a given base assignment list.
build-environment-assignmentsprocedureAssemble the ordered NAME=VALUE list for env -i.
PACKAGE-ENV is the recipe's raw env:; its values are interpolated here, at the same argv level and against the same DIRECT dependency prefixes as phase arguments, so ${dep} means the same thing in both and an undeclared name is the same error. Build inputs are deliberately absent from DEP-PREFIXES -- see (sigil env package) validate-interpolations.
*sandbox-loader-paths*variableConventional absolute paths at which a dynamic loader is expected, and the relative path a DECLARED input must ship to provide one.
A loader is never ambient. It appears inside the sandbox only because some declared dependency shipped it, and nothing else in the root can supply one -- so an undeclared loader fails at exec, loudly, instead of resolving to whatever the host had. The spike measured what the other behaviour costs: zig's native detection has nothing to probe in an all-static root, and its fallback produced a SEGFAULTING temacs rather than any kind of error. A segfault is a much worse diagnostic than ENOENT.
*sandbox-required-tools*variableSeed tools the assembly script invokes. Checked by name before the namespace probe runs, because a seed that no longer ships unshare would otherwise be indistinguishable from a host that forbids user namespaces -- and would silently take the degraded path for a reason that has nothing to do with the host. A missing tool must be an error, never a quieter test.
sandbox-modeprocedureWhich builder mode will this process use? -> 'namespaces | 'none
This is answered from the seed TARBALL rather than from an extracted seed directory, because P1.2 puts the answer in the derivation record and the record is built before any seed has been unpacked. The verdict is cached per process, so make-external-derivation and external-builder cannot disagree -- a record that claimed one mode while the build took the other would be a lie of exactly the kind the sandbox: field exists to prevent.
The probe asks for EVERY namespace flag the builder uses rather than a cheaper subset: a host can permit unshare -U and refuse -p, and a probe of the easy neighbour would report a sandbox we cannot build in. The spike hit the same trap from the other side -- zig version needs no procfs and zig cc does. Probe the operation you ship.
*probe-root-counter*variableA private scratch directory for the one-shot probe extraction. Not in the store: the probe runs before any store path is chosen, and its output is not an input to anything.
sandbox-loader-bindsprocedureLoader binds contributed by the DECLARED inputs, in dependency order. Returns ((host-file . conventional-absolute-path) ...). The first declaring dependency wins, and dependency order is already canonical, so the result is a function of the declaration rather than of the filesystem walk order.
A DECLARED LOADER THAT CANNOT BE USED IS AN ERROR, NOT AN ABSENCE (P1.3).
file-exists? FOLLOWS symlinks, so on its own it answers only "is there a usable file here" and CONFLATES two different facts: this input ships no loader, versus this input ships a loader I cannot use. Skipping is right for the first and catastrophic for the second -- the bind silently does not happen, the build runs to completion, and the failure surfaces much later as an ENOENT at exec, or as a segfault, with nothing pointing back at the declaration.
This is not hypothetical. MEASURED 2026-07-31 on musl 1.2.5's own install rule: $(INSTALL) -D -l $(libdir)/libc.so $(LDSO_PATHNAME) writes lib/ld-musl-x86_64.so.1 as an ABSOLUTE symlink to /sigil/store/<item>/lib/libc.so. That target exists inside the sandbox and does NOT exist on the host, where this assembly runs -- so an unpatched musl package produces an item whose loader is a dangling symlink from here, and the old code skipped it without a word.
symlink? does NOT follow, so the pair (symlink? OR file-exists?) = "an entry is declared here" and (file?) = "it resolves to a regular file" separates the two facts. Refusing needs both.
sandbox-assembly-scriptprocedureThe namespace assembly, as a script the SEED's shell runs inside unshare -U -r -m -n -p -f. Every tool is addressed through the seed's own bin directory: the assembler is part of the declared build environment, not the host's userland.
INPUT-BINDS is ((host-path . sandbox-path) ...) for the source, the patches and every declared dependency. Nothing else is bound, so an undeclared store item is not merely unreadable by policy -- it does not exist in the namespace at all.
assert-no-host-paths!procedureAfter a sandboxed build, assert that no host-specific string reached the output. This REPLACES the build-root byte rewrite on the sandboxed path: with every host path unmounted there is nothing legitimate to normalize, and a rewrite would only hide a leak that must be an error. The check is falsifiable in the useful direction -- if some future change reintroduces a host path, this fails instead of laundering it.
*external-unsandboxed-outputs*variableDerivation outputs built WITHOUT the sandbox in this process. Consumed by external-realize, which persists the fact as store metadata so it outlives the process that made it.
external-assert-publishableprocedureRefuse to publish a store item that was built without the sandbox.
A degraded build is perfectly fine to USE on the machine that made it. What it is not is a portable identity: its prefix contains that machine's store root, its inputs were whatever the host filesystem had, and its network was open. Publishing it as a channel artifact or a substitute would be a degraded CLAIM rather than a degraded build, and those are the two very different things §3 separates.
external-realizeprocedurerealize(recipe) -> output tree hash. Exact drv-key lookup happens in the store; store-build supplies atomic idempotent directory insertion.
external-challengeprocedureForce one independent rebuild and compare it to the exact drv-key item. Allowed mismatches are loud and persisted in store-owned metadata.
external-fetch-outputprocedurefetch-output(hash) -> verified store path.
named-dep->sealvariable(No description)
read-external-recipevariable(No description)
patch-sourcesvariable(No description)
external-systemvariable(No description)
external-check-divergencesvariable(No description)
external-normalize-build-root!variable(No description)
external-toolchain-hashvariable(No description)