design

ariza β€” design record

Decisions and their reasons. The README says how to use ariza; this says why it is shaped the way it is, and what each shape is protecting against. Where a decision arrived with a release, the release and its date are named β€” Changes has the full entry.

Everything here is a ruling that something in lib/ or resources/ now depends on. If one of them stops being true, the code is what has to change, not this file.

1. Why a bundle, and not a zef install

ariza packages Raku TUI applications β€” Cantina, Kelpie, Mindmoor, Moneymoor β€” for people who do not have Raku and should not have to care that the thing is written in it.

The alternative was the one every Raku project starts with: tell the user to install Rakudo, then zef install App::Whatever, then install the native libraries their platform spells differently from everyone else's. For a developer that is a two-minute job. For the person these apps are actually for it is a support conversation with five branches in it, most of which end in a compiler error inside a dependency they have never heard of.

So the end state ariza is built towards is a bundle: one archive per platform containing the application, a Rakudo runtime, every Raku dependency, and every native shared library the app touches β€” notcurses, SQLCipher, libvips β€” laid out so that unpacking it anywhere and running a single launcher works.

The properties that matter, and that shape everything below:

  • Self-contained. Nothing is fetched at run time and nothing is assumed to be present. A machine with no Raku, no Homebrew and no compiler runs the app.

  • Per-platform, and honest about it. A glibc binary does not run on Alpine; an arm64 dylib does not run on an Intel Mac. Every artefact is named after exactly one platform slug and contains binaries for exactly that platform.

  • Relocatable. No absolute paths baked in, no install step, no PATH surgery. It runs from a home directory, a USB stick or /opt.

  • Removable. Deleting the directory removes the software. Nothing is registered, nothing is scattered, nothing is left behind.

The corollary, taken in 0.0.1 (2026-08-12): there is exactly one distribution mechanism. The legacy installer machinery β€” App::Ariza::Legacy, eight templates and two shell partials, per-app configs, a SQLCipher mirror reference, ariza render-legacy and a parity test β€” was retired in that release rather than carried alongside the bundle path. Apps get retired rather than carried, and an app that is ready to ship ships the way Moneymoor does: a bundle, four generated installer scripts, two workflows. Keeping a second mechanism alive for one unreleased app meant two ways to install everything, two things to keep true of every pin bump, and a parity gate that could only ever say "the old thing still renders the old bytes".

The absorption was not wasted. The parts of the legacy partials a bundle installer still needs β€” the marker-block PATH persistence, the shell-rc detection β€” were adapted into resources/templates/installer-common-{posix.sh,windows.ps1}. What was deleted is only what drove package managers, refcounted a shared registry and installed a terminal emulator: everything a bundle exists to not do.

2. The vendored runtime, the closure, and the warm store

The runtime is the official archive from rakudo.org, pinned by [rakudo] in versions.toml, downloaded once into $XDG_CACHE_HOME/ariza/rakudo and unpacked into the bundle. The cache records a SHA-256 on first download and verifies it on every reuse.

The Raku closure is installed by the bundled zef into the bundle's own repository, so the precompiled bytecode matches the runtime that ships rather than whatever Rakudo the build machine had. Using the system zef would work, and would produce a bundle that recompiles its entire closure on first launch, because precompiled bytecode is tied to the exact Rakudo that produced it.

The bytecode is warmed at build time. zef does not do this, and it matters: a cold bundle spends about 55 seconds compiling on its first launch β€” measured for Moneymoor, against ~0.5s once warm β€” and does it again on every launch if the bundle happens to live somewhere unwritable, which for a downloaded archive is normal. Even with the repository in the chain, zef install leaves most of the closure uncompiled, because the first process to use a module is what compiles it; so build-site loads every module the app distribution provides, under the bundled runtime, with the bundle's environment, and the transitive closure comes in as a side effect of loading what uses it. The store is content-addressed, and every dependency in it is recorded relative to the repository that holds it, so warming it at build time and unpacking the archive somewhere else entirely β€” including a path with spaces β€” loads the same bytecode. That last property is why the app's repository is the bundled runtime's own vendor prefix, <bundle>/rakudo/share/perl6/vendor, and not a directory of ariza's choosing: Rakudo writes vendor#sources/<id> for a repository the registry has a name for β€” core, vendor and site under the running interpreter's prefix, home under $HOME β€” and an absolute build-machine path for any other, including one named only in RAKULIB. A store full of the second kind is thrown away wholesale by the first machine that is not the build machine, which is the bug the first published bundle shipped with: 52 modules recompiled, ~58 seconds, on every user's first launch, invisible to every check that ran on the machine that built it. build-site now asks the bundled runtime what it calls the repository before warming anything, and reads the records back afterwards; ariza smoke reads them again in the unpacked archive.

The launcher is the one file a user runs, and the only file in the bundle they are ever expected to touch. It finds its own bundle by resolving its own path through a readlink loop, not readlink -f, which is a GNU extension absent from macOS before Monterey and from the BSDs β€” a launcher that only resolves symlinks on Linux breaks the moment someone puts one in ~/bin. It exports RAKULIB=inst#<root>/rakudo/share/perl6/vendor and unsets PERL6LIB, so the bundle's repository is the only one: a RAKULIB left in the user's environment would otherwise put modules compiled against a different Rakudo ahead of the bundle's. It exports NOTCURSES_NATIVE_DATA_DIR and deliberately not NOTCURSES_NATIVE_LIB_DIR, which suppresses Notcurses::Native's own TERMINFO_DIRS setup and leaves a TUI without terminfo. Everything is quoted, so a path with spaces works.

Two smaller rulings live in the launcher. A wrong TERM produces a warning, never a refusal: TERM is frequently wrong in environments where the app still runs fine, and a launcher that second-guesses the user's terminal is worse than one that mentions it. And the first launch prints one notice about paging a few hundred megabytes off disk, marked by a single file under XDG_STATE_HOME β€” the only thing any bundle writes outside its own directory.

3. Native self-containment, and an audit that refuses to ship

Native libraries are staged and then audited: every Mach-O, ELF or PE ariza put in the bundle must resolve inside it, or the build fails naming the file and the dependency. That is the difference between a bundle that works on the machine that built it and one that works anywhere.

The audit is not advisory. It is the last step before an archive is written, and a finding stops the build. This matters more than it sounds, because every machine likely to run it is a machine where the gap it is looking for is invisible.

3.1 macOS

A Homebrew library names its dependencies by absolute path, so the problem is obvious and the fix is mechanical: copy each non-system dependency in beside the library, rewrite the load commands with install_name_tool so they resolve relative to the bundle, and re-codesign anything that was modified.

3.2 Linux, and why it needs a Linux machine

ELF hides the same problem better than Mach-O does, which is why it went unnoticed longer. A Homebrew library names its OpenSSL by absolute path, so it is obvious. A distribution's libsqlcipher.so.0 names its OpenSSL as a bare NEEDED libcrypto.so.3 β€” no path, no RPATH, nothing in the file for an audit to object to β€” and at run time that means "whatever libcrypto.so.3 this machine has", which on a user's machine may be nothing at all.

So a Linux bundle gets the same treatment a macOS one does, spelled the way ELF spells it: ldd (the loader itself, so its answer is the one the machine will really give) reports where each dependency resolves; every non-system one is copied in beside the library under its soname and recursed into; and patchelf --set-rpath '$ORIGIN' on the library and every copy makes each find its siblings in its own directory wherever the bundle is unpacked.

What stays dynamic is the loader, libc and the compiler runtime β€” a second copy of those is a conflict, not insurance. libcrypto and libssl are not in that category: SQLCipher without OpenSSL is SQLite.

The audit then adds two checks that only exist on a Linux host, because both tools answer for the machine they are on: patchelf --print-rpath must be $ORIGIN-relative, and ldd run with the environment replaced by a bare PATH β€” the user's view, not the build's β€” must resolve every non-system dependency inside the bundle. Cross-inspecting an ELF from a Mac does the static half and says so, rather than pretending to more; which is the long way of saying the Linux bundle is built on Linux.

That claim is proved rather than asserted. xxt/linux-selfcontain-proof.sh runs the whole thing in a manylinux container β€” real SQLCipher, real Rakudo, the test suite, a real bundle staged and audited, the result re-checked independently in shell, and three negative controls (a planted absolute NEEDED, a deleted dependency, a stripped rpath) that must each make the audit fail.

3.3 Windows static closure, and why it needs no Windows machine

PE hides it worst of all, because it records nothing to hide. A vcpkg sqlcipher.dll imports libcrypto-3-x64.dll by bare name β€” no path, no rpath, nothing in the file that an audit reading tags could object to β€” and a bundle staging the DLL alone does not load the user's OpenSSL the way the Unix failure does. It does not load at all: LoadLibrary fails resolving imports, and the app reports DBDish::SQLCipher needs 'sqlcipher.dll', not found β€” a message about the file that is there, produced by the file that is not.

This is the bug fixed in 0.0.6 (2026-08-12), and it is worth recording that the two previous fixes for the same symptom (0.0.5's runtime-env PATH, 0.0.4's zef.raku) were both real bugs and neither could have made it go away, because there was no OpenSSL in the bundle for any PATH to point at.

So a Windows bundle gets the same walk-and-copy, spelled the way PE spells it. ariza reads the DLL's own import table β€” DOS header, PE signature, optional header, data directory, section table; about eighty lines of Raku, because there is no tool to shell out to. dumpbin ships with Visual Studio and objdump with neither, and a dependency walk that silently finds nothing when its helper is missing is how this hole stayed open. Everything that is not Windows' own β€” not KERNEL32, not the API sets, not the Visual C++ redistributable (Β§3.4) β€” is copied in beside the library and recursed into. That establishes a complete, adjacent closure; it does not establish the live loader's search. The launcher separately puts this exact directory on PATH, because loading the top DLL by absolute path does not make ordinary dependent-DLL search inspect its sibling directory.

The staging copy search space is exactly one directory: the one the library came from. Both Windows package managers install a port's whole runtime closure into a single bin β€” vcpkg's installed/<triplet>/bin, MSYS2's ucrt64/bin β€” so sqlcipher.dll's OpenSSL is the file sitting next to it, and searching wider (PATH, System32) would find a same-named DLL from a different build of a different version, which is the bug a bundle exists to avoid. An import that is not there fails the build, naming it and the directory searched.

Unlike the Linux pass, this static closure work needs no target host: reading a PE's imports means reading bytes, not running a loader. A Windows bundle can be assembled, walked and audited from a Mac and get the same closure verdict it would get on Windows. That verdict does not claim the live loader used the launcher's PATH; the target-platform smoke proves that separately.

3.4 The one import that looks like Windows and is not

vcruntime140.dll is on the skiplist β€” ariza does not copy it in β€” and the audit reports it anyway when the bundle does not carry it, because it is not part of Windows. The Visual C++ Redistributable arrives with Visual Studio, with its own installer, or dragged in by some other application; kernel32.dll and ucrtbase.dll are in System32 on every Windows 10 machine ever installed, and vcruntime140.dll is not on any of them by default.

That makes it the worst kind of dependency to have: a DLL importing it loads on the machine that built the bundle, on every CI runner, and on most developer laptops β€” and refuses to load on a clean install, which is the only machine a bundle exists for. Every machine likely to run the audit is a machine where the gap is invisible; the release lane in particular could not have caught it, because a GitHub runner has the redistributable.

An MSVC-built SQLCipher imports it; that is why ariza's own CI lane takes MSYS2's UCRT package instead of vcpkg's port. If one is staged anyway the audit fails the build and says all of that, because the person reading the failure has just watched the bundle work. Carrying the runtime in the bundle satisfies the check too β€” it is containment that is being asked about, not abstinence β€” but ariza will not copy it in on an author's behalf: it is Microsoft's to redistribute, on Microsoft's terms, and app-local deployment of it is a decision that belongs to whoever signs the release.

The gate is a family, not one file: vcruntime*.dll, msvcp*.dll, concrt*.dll, vcomp*.dll. ucrtbase.dll is deliberately not in it, which is the entire reason a UCRT build is the answer and not another instance of the question. (Unreleased, following 0.0.6, 2026-08-12.)

4. Where SQLCipher is allowed to come from

notcurses arrives on its own β€” Notcurses-Native's prebuilt pack is staged as a side effect of the zef install. SQLCipher has no such hook, so ariza takes it from the build machine's own package manager:

  • macOS β€” the installed Homebrew keg (brew --prefix sqlcipher), or, if it is not installed, the bottle brew fetch --formula sqlcipher puts in the cache.

  • Linux β€” whatever ldconfig -p resolves libsqlcipher.so.0 to, falling back to the Debian multiarch, Red Hat and Alpine library directories. CI installs the distribution package before it calls ariza bundle.

  • Windows β€” an MSYS2 environment's bin (MSYSTEM_PREFIX, then C:\msys64\ucrt64\bin), a vcpkg tree under VCPKG_ROOT, or wherever SQLCIPHER_LIB_DIR points.

4.1 No mirror

There is deliberately no ariza-operated mirror to download from. SQLCipher's ABI does not move often enough to justify a second piece of release infrastructure with its own signing story, its own staleness and its own outages β€” and what makes a library safe to redistribute is not where it was downloaded from but what happens to it next: every dependency it names outside the bundle is copied in beside it and rewritten to load from there, and the audit then refuses to ship the bundle if anything still points off the machine.

4.2 Two package managers, two names, one staged file

vcpkg's port produces sqlcipher.dll and MSYS2's produces libsqlcipher-0.dll. ariza accepts either β€” the canonical name wins where it is found, per directory, in priority order, matched case-insensitively as the loader matches it; where no directory holds it the search widens to libsqlcipher*.dll, newest first by the same numeric-aware comparison the Linux pass uses for EPEL's renamed soname β€” and stages whatever it finds under the canonical one.

That rename is safe for exactly the reason the Linux one is: LoadLibrary resolves a DLL by the leaf name on disk and never consults the module's internal name, just as dlopen never consults DT_SONAME. The manifest's origin records what was actually found, so nothing is quietly renamed behind the author's back. (Unreleased, following 0.0.6, 2026-08-12.)

4.3 UCRT, not MSVC

ariza's own Windows CI lane installs mingw-w64-ucrt-x86_64-sqlcipher with the pacman every windows runner already has at C:\msys64, rather than building vcpkg's port. Two reasons, and the first is Β§3.4: an MSVC build imports vcruntime140.dll and an MSYS2 UCRT build imports ucrtbase.dll, which Windows 10 and later ship. The second is mechanical β€” the MSYS2 package is prebuilt where the vcpkg port was a fifteen-minute source build, which is why the lane used to carry an actions/cache restore/save pair wrapped around it. Both are gone. pacman -Sy refreshes the databases first, deliberately: the runner image's copy is as old as the image, and MSYS2's mirrors keep only current versions, so a stale database sends pacman after a file that is no longer there.

vcpkg is still probed rather than dropped β€” a machine that has only that should build rather than stop β€” and the audit is what refuses to ship the result.

4.4 The overrides, and the cross-build refusal

Two overrides beat the package managers, in this order: --sqlcipher-archive=FILE (an archive to unpack) and SQLCIPHER_LIB_DIR (a directory holding the library). Both are how a cross-build works, because they are the only way to be honest about it: a library installed on this machine is built for this machine, so ariza refuses to take one when the platform being built is not the platform it is building on. Missing entirely, on any platform, is a death naming the package to install and the override to pass β€” never a silent bundle without a database.

4.5 The pin is advisory, the manifest is not

The version in versions.toml is advisory. The package manager decides what is actually installed, so ariza reads the version out of the staged library's own bytes and warns if it disagrees with the pin:

ariza: sqlcipher 4.17.0 staged, pin says 4.14.0

The build continues, and ariza-manifest.json records the version that was staged, the pin beside it, the digest of the library as it came off the machine, and which keg, bottle or archive it came from. A manifest that quotes a number nobody verified is worse than no number at all.

5. Proving it, rather than hoping: the smoke harness

ariza smoke takes the finished archive, unpacks it into a scratch directory with a space in its name, and runs it with a replaced environment β€” PATH, HOME, TERM and nothing else (plus the handful of variables without which no process starts on Windows).

The replaced environment is the point. run's :env substitutes the child's whole environment, which is env -i without needing env. The failure this catches is a bundle that quietly uses something from the developer's shell β€” a system Rakudo on PATH, a DBIISH_SQLCIPHER_LIB pointing into Homebrew, a RAKULIB that already has the app installed in it. Any of those makes a broken bundle look perfect on the machine that built it.

The space in the path is also the point. ariza smoke 51234-882931/ is where the bundle goes, and if the launcher's quoting slips anywhere, that is what notices β€” before a user with ~/Application Support/ or C:\Program Files\ does.

Two environments, for two kinds of command. A command that starts with {exec} goes through the launcher, so it gets the bare environment above: that run is a test of the launcher's ability to set up its own world. A command that starts with {raku} drives the bundled interpreter directly, standing in for code running inside the app, so it additionally gets exactly what the launcher would have exported β€” RAKULIB, NOTCURSES_NATIVE_DATA_DIR, and the native-library variables: LD_LIBRARY_PATH on Linux; on Windows, PATH begins with the exact Notcurses tag/lib directory followed by SQLCipher's directory so their dependent DLLs are found. DBIISH_SQLCIPHER_LIB names SQLCipher directly on both. Without any of that it would be testing the absence of a launcher rather than the bundle.

Nothing goes through a shell, in any form, which is what makes it reasonable for an app to smoke-test its database engine rather than only --version: the argv shape can carry an entire Raku program with no quoting layer to get wrong.

Every check runs; nothing short-circuits. "The launcher failed" and "the launcher failed and the audit found a stray library" are different bug reports and should not require two runs to distinguish.

Failure leaves the evidence. On success the scratch directory is removed; on failure β€” or with --keep β€” it stays, and its path is printed. The whole value of a failed smoke is the state it failed in.

One check exists for a bug that nothing else would catch. The manifest check asserts the shape β€” that every entry in dists is an object with a name β€” because a Raku { } literal whose body starts with a .method call is parsed as a Block, and a Block of pairs serialises as an array of one-key objects, producing a manifest that parses, reads plausibly and lists nothing at all. The precomp check is the same class of thing: an empty precompilation store is not a crash, it just makes every launch slow, forever, on a read-only bundle.

6. Getting a bundle onto a machine

A bundle is self-contained, so "installing" it is unpacking it somewhere and running the launcher. The generated installers do exactly that, and nothing more.

6.1 One POSIX script, not two

The installer scripts that came before a bundle shipped install-macos.sh and install-linux.sh separately, because they did genuinely different things: one drove Homebrew, the other five package managers. A bundle installer does none of that, so the only per-platform decision left is which asset to download, and that is a uname call away at run time. One script, one curl line in a README, and nobody has to work out which of two files applies to them.

6.2 It is meant to be piped, so it never asks

A script read from a pipe is executed as it arrives, so the generated one is entirely definitions with a single main "$@" on the last line: a truncated download cannot half-run it. It also never reads standard input β€” no prompts, no confirmations β€” because when the script arrives on standard input there is nothing left to read from. That is not a limitation to work around; it is what makes the thing runnable from CI.

6.3 Nothing that exists is touched until the new tree is complete

The download unpacks into $XDG_DATA_HOME/<exec>/versions/<version>/, beside whatever is already there, and only then is the current symlink flipped and ~/.local/bin/<exec> linked at it. A failed or interrupted download cannot damage a working install. One previous version is kept, so a bad release can be rolled back by hand. β€œPrevious” is the physical bundle that current resolved to immediately before the switch, not whichever directory has the newest mtime: rollback A, then install C must retain A, not the abandoned B. Pointer changes are journaled and recoverable; after a commit, anything other than the new current and that exact predecessor is pruned.

~/.local/bin goes on PATH only if it is not there already, through one marked block appended to each shell rc file that exists. Re-running never duplicates it; the uninstaller removes exactly that block and nothing else.

It does not create desktop entries, register file associations, install a terminal emulator, or write to any shared location. A bundle needs none of it, and the shared registry the older installer scripts kept existed only to refcount things this one never installs.

6.4 Re-running is a repair

Asking for a version that is already installed does not re-download it: the existing tree is checked, both links are re-pointed, the PATH block is re-checked, and the script exits 0 saying "already installed". That makes "run the installer again" the correct advice for the most common breakage β€” a link someone deleted, or a shell that never picked up PATH. An existing version directory with no runnable launcher in it is not a version, and is replaced rather than trusted.

6.5 The escape hatch, and the one flag it applies to

--url (and <EXEC>_BUNDLE_URL) installs from a source the user names and bypasses GitHub entirely. It takes a plain file path as readily as a URL, which is what makes an air-gapped install, a release candidate, and ariza's own end-to-end test possible with no network and no published release.

--insecure-no-verify applies to that path only. A source someone named themselves may legitimately have no .sha256 beside it, and the script says so loudly before continuing; a published release always has one, so a missing digest there stays fatal however many flags are passed.

The default source is the latest release, read from the location: header of https://github.com/<owner>/<repo>/releases/latest: one HEAD request, no API token, no jq.

6.6 The install warms the app up, and a failed warm-up is not a failed install

The last thing an install does before it says goodbye is run the thing it just installed, once: <exec> --version by default, or whatever installer.warm names, with its output suppressed and a line on screen saying what is happening. Whatever a first launch has to do that later ones do not β€” paging a few hundred megabytes off a cold disk, creating a per-user state directory, unlocking a keychain β€” is done there. A user who types the command for the first time expecting their application should get their application, not a progress-free pause of unknown length.

Every path that reaches the parting message goes through it, including the re-run that found the version already installed. That re-run is what somebody tries when the last one did not take, and warming it again costs a second.

The ruling: a warm-up that fails warns loudly and the install succeeds. No non-zero exit, no rollback, no "installation failed".

This deliberately diverges from the fail-closed posture everywhere else in this tool. ariza bundle dies on a missing licence; ariza smoke fails on a stray library; the audit refuses a dependency it cannot resolve inside the bundle. The difference is the failure domain. All of those run in our own pipeline, on a machine we control, before anything is published: there, a failure is cheap, a false negative is expensive, and the correct response to "something is off" is to stop. The warm-up runs on a stranger's machine, after the bundle has been downloaded, checksummed and moved into place. There, the same signal means something quite different β€” no terminal, a sandbox, a policy, a scanner holding the file open, a machine with no display β€” and the program on disk is, on the evidence available, fine. Failing the install would delete a working program from somebody who has one, and would teach them that this installer is unreliable, on the strength of a diagnostic step.

So the failure path says two things and stops: what did not complete, and that the app is installed and its download was verified, so run it β€” the first launch may just take longer. That is a true statement about a bundle in that state, which is the test any message printed on somebody else's machine has to pass.

Two consequences follow. There is no timeout β€” a mechanism that would need one implies a warm command that might not return, and the right fix for that is the command, not a watchdog β€” which is why an empty installer.warm is a load-time error rather than "run the app with no arguments". And the default is --version rather than anything cleverer, because it is the one invocation every bundled application answers and returns from, and it is already the canary bundle.smoke uses.

6.7 Windows

The same shape in PowerShell: %LOCALAPPDATA%\<Display>\versions\<version>, a current junction rather than a symlink β€” which would need administrator rights or Developer Mode, and a per-user install has no business demanding either β€” and ...\current\bin added once to the user PATH in HKCU\Environment. Because the PATH entry points through the junction, an upgrade needs no PATH change at all.

6.8 Startup update prompts are generated policy, not application code

Managed update prompts are opt-in through [updates] enabled = true. They are implemented by a generated, core-only Raku coordinator inside each bundle, not by the application: cadence, stable-release grammar, prompt state, installer selection and relaunch behavior are distribution policy shared by every app ariza bundles. The app supplies only its identity, current version and the existing installer.repo, all baked into the generated program.

The check is before application dispatch, but only after the launcher proves this is an interactive run through the installer-managed current. Portable archives, retained versions invoked directly, redirected sessions, CI, help/version requests, an explicit ARIZA_NO_UPDATE_CHECK=1, and the guarded post-update hop all dispatch immediately. A timestamp is written before the bounded network request, so an offline machine waits the full weekly cadence instead of paying for the same failure at every startup. A non-blocking file lock makes two simultaneous launches choose β€œone checker, one ordinary launch”, never two prompts.

Discovery follows only the configured repository's releases/latest redirect and accepts only a bare ASCII X.Y.Z tag. No API response, channel list or semver prerelease policy is involved. Components compare as normalized decimal strings rather than machine integers, so the grammar has no accidental word- size limit and leading zeroes remain accepted as decided.

The three prompt outcomes are persisted literally: install and use now; keep the candidate pending and ask next eligible launch; or ignore exactly this version. Ignoring 1.2.3 says nothing about 1.2.4. Choosing install invokes the trusted installer snapshot already in the old bundle through a private interface that has no URL, version or insecure override. It derives the exact GitHub asset, requires its checksum, and validates the extracted manifest's application, candidate, repository, protocol and bundle-relative updater paths before a pointer moves.

Reserved exit 75 alone is not success: an application is entitled to return any status. The launcher creates a 256-bit nonce and a private result path, the installer writes an exact path-free record only after the switch commits, and the launcher compares the complete record and resolves current against versions/<candidate> before honoring it. Only then does the explicitly chosen β€œInstall & use” perform one relaunch with the original argv. POSIX can own that protocol in its shell launcher. Windows needs the native process to preserve the raw argument tail and own the challenge, so update-enabled Windows bundles require runner-v2 or later and the .cmd/.ps1 twins delegate to it.

Windows cannot remove the bundle whose executable is still running. Retention there therefore records bounded cleanup work and retries it on a later install, after checking that every target remains a physical child of versions and is not current, previous, the running expected-current, or a reparse point. A failed cleanup is disk usage, never authority to widen deletion.

7. The release pipeline

ariza bundle builds one bundle, on one machine, for the platform that machine is. A release needs one per declared platform, each built on a machine that is that platform β€” SQLCipher comes off the build host, so there is no cross-build to fall back on β€” and all of them published together. That is a CI problem, and ariza scaffold-ci writes the CI.

Neither generated file is ariza's to run. Both are committed to the app's repository and run by GitHub, exactly as install.sh is committed and run by a user. ariza's job is to make sure the file says the right thing about this app.

7.1 The Linux lane's shape

It builds inside quay.io/pypa/manylinux_2_28_x86_64 so the archive's glibc floor is 2.28 β€” RHEL 8+, Ubuntu 18.10+, Debian 10+ β€” rather than whatever ubuntu-latest happens to ship this month; a bundle cannot be older than the machine that built it. And Raku/setup-raku cannot install into a container (it writes to the runner's tool cache, which is not in the container's filesystem), so the lane resolves the [rakudo] pin against the same JSON release index App::Ariza::Rakudo reads and unpacks the archive itself. There is no URL to construct β€” upstream filenames carry a toolchain suffix β€” so the index is the only honest route.

7.2 Which Raku is which

Every lane installs a Raku to run ariza with, and it is not the runtime that ends up in the bundle: ariza downloads the pinned Rakudo for itself, verifies it, and unpacks that into the archive. The lane's own Raku can be any version, which is why setup-raku asks for latest rather than for anything in particular.

7.3 Dispatch before you tag

The workflow triggers on workflow_dispatch as well as on push: tags: ['v*'], and a dispatch run stops after the build lanes: publish and every smoke-installer-* job are gated on startsWith(github.ref, 'refs/tags/').

That is the iteration loop, and the reason the dispatch trigger exists. A recipe that has gone stale β€” a renamed package, a runner image that moved on, a package version the mirror no longer keeps β€” costs a run and a push to a branch, rather than a burnt tag and a deleted release. The dispatch input ref takes a branch, so the lane being fixed does not have to be on the default branch to be tried.

Every one of those hazards has been real. 0.0.3 and 0.0.4 (2026-08-12) between them fixed a dead configure flag (--enable-tempstore, which SQLCipher's move to SQLite's autosetup respells --with-tempstore), an OpenSSL 3 requirement EL8 parallel-ships rather than installing as the system OpenSSL, a library autosetup names libsqlite3 unless told otherwise, an ldconfig-based sanity check that could never match because the SONAME is deliberately libsqlite3.so.0, and a shell: bash default that made zef resolve tar off Git Bash's MSYS build, which reads a leading C: as a remote hostname.

7.4 The publish gate

publish collects every lane's artefact, flattens it, recomputes a combined checksums.txt, and checks that against the .sha256 sidecars ariza bundle wrote β€” a release whose digest refuses its own archive is worse than one with no digest β€” before cutting the GitHub release with a body saying what a bundle is, which machines each archive runs on, and how to verify a download.

7.5 One installer smoke per platform

0.0.3 (2026-08-12) generalised what had been a Linux-only job. Every declared platform with a GitHub-hosted runner gets its own clean-machine installer smoke: smoke-installer-macos-arm64, smoke-installer-linux-x86_64-glibc, smoke-installer-windows-x86_64. Each takes the archive that was just published, on a plain runner with nothing installed on it, installs it with the repository's own committed install.sh or install.ps1, and runs the installed launcher under a stripped environment β€” env -i on POSIX, a from-scratch System.Diagnostics.Process on Windows, which has no env -i.

The principle: these are the only jobs that exercise the artefact a user will actually receive, by the path they will actually take. Everything before them tests something ariza made; this tests what a stranger downloads. macOS's is incidentally the only place install.sh's BSD branches (shasum -a 256, bsdtar) ever run in CI.

The macOS/Linux/Windows split lives in one place, %LANES in App::Ariza::CI, alongside each platform's build lane. A platform with a build lane but no smoke recipe yet renders no smoke job and a comment in the workflow says so, instead of silently having none.

7.6 What is regenerated, and what is yours

release.yml is derived from bundle.platforms: add a platform, re-run scaffold-ci, and the file gains a lane and a needs: entry. It is rewritten in place every time, so a hand edit to it is an edit you will make twice.

test.yml is written only when it is absent. A test workflow acquires system dependencies, extra jobs and skip conditions that no generator can infer from a manifest β€” the scaffolded one is a starting point in the house shape, and the moment it is committed it is the repository's. --force overwrites it anyway, for when the house shape has moved on and the local edits are known to be nothing. Each generated file's header says which of the two it is, so nobody has to remember.

For the same reason, an app whose test suite needs its native libraries has to add those steps itself: bundle.native says what a bundle carries, which is a different question from what a test run needs on the runner. The scaffolded test.yml names the libraries and says it is not installing them, rather than guessing either way.

7.7 The platforms it will scaffold

macos-arm64, linux-x86_64-glibc and windows-x86_64: the slugs with both a GitHub-hosted runner and an official Rakudo build behind them. A declared platform outside that set is a hard error, not a skipped job β€” the same reasoning as an unknown slug in bundle.platforms, since silently dropping one produces a release quietly missing a platform the author asked for. A bundle for one of the other five is built by hand today, and a generated lane for it would be a guess rather than a recipe.

8. Configuration rulings

8.1 The app declares; ariza does not know about apps

What a given app needs is declared by that app, in an ariza.toml in its own repository β€” so ariza stays a general tool instead of growing a list of special cases about specific applications. All three [app] keys are required, and display is required rather than derived because the correct capitalisation of a product name is not something a tool should guess at.

8.2 Declared platforms are enforced

Building a platform the app does not list in bundle.platforms is an error: an app that lists its platforms has said which ones it is tested on, and producing an artefact named after one it never claimed is a promise ariza has no business making on its behalf. The same list is what the generated installers detect against, so an app that has never built for musl says so on an Alpine box rather than downloading something that cannot run there.

8.3 Unknown keys warn; wrong types die

The house rule, shared with App::Shigur: an unrecognised key at any level is collected into warnings and loading continues, so one file can serve several ariza versions in either direction. A wrongly-typed value dies immediately, naming the dotted path and the expected shape β€” ariza: bundle.platforms must be an array of strings β€” because a mistyped pin would otherwise be silently baked into an artefact. Keys beginning with // are ignored entirely, without a warning, in every ariza config file; TOML has real comments, so the convention is redundant there, and it is kept only so a config author never has to remember which format they are in.

8.4 The closed sets

There are exactly three value-level exceptions to "unknown warns", and each is a set that cannot grow by a user's typo:

  • A platform slug in bundle.platforms dies rather than warning. A typo cannot be a future feature, and ignoring it would ship a release quietly missing a platform the author asked for. macos-aarch64 for macos-arm64 is the mistake this catches.

  • installer.repo must be a bare GitHub owner/name. It is interpolated straight into three URLs, so anything that is not that shape produces a 404 at the one moment a user is least equipped to debug it.

  • ARIZA_PLATFORM must name a known slug. Every other input to detection is a system fact that might legitimately be unnameable, but an override is a human typing a string, and quietly ignoring a misspelled one would produce an artefact for the wrong platform.

ci.ariza-source is deliberately not closed β€” it is anything zef install accepts β€” so only an empty value is an error, because that would render a step that installs nothing and succeeds.

8.5 Platform slugs belong to Notcurses-Native

The eight slugs are not ariza's to choose. They are Notcurses-Native's platform slugs, character for character, because a bundle carries that distribution's prebuilt notcurses libraries and the two have to agree about what platform they are on. Detection on Linux probes for a musl loader first (its presence is conclusive) and falls back to parsing ldd --version; a system with neither β€” uclibc, a static busybox image β€” produces an honest "unsupported platform" rather than a bundle that will not run.

8.6 One pin file, two shapes

sqlcipher is a flat pin compared against whatever the build machine staged (Β§4.5). [rakudo] is a table, because the runtime a bundle embeds needs two coordinates: version and revision, where revision disambiguates rebuilds of the same upstream release β€” a repackaged runtime, a patched MoarVM β€” without pretending upstream cut a new version. The shapes are not interchangeable, and a bump to either re-renders every artefact in lockstep, which is the entire reason the numbers live in one file.

9. Finding ariza's own data files

ariza ships data: Jinja2 templates, shell partials, the pin file. It has to read them both when installed (where zef has staged every resource under a content-hashed name and %?RESOURCES is the only way back to the bytes) and from a source checkout (where %?RESOURCES is typically unpopulated and the files are sitting in resources/). App::Ariza::Resources is the single place that knows this, so neither world is a special case anywhere else.

resource-list exists because %?RESOURCES cannot list a directory β€” an installed distribution's resources are flat. A checkout reads the working tree, so a file you just created is visible before you have listed it in META6; an installed run enumerates the distribution manifest, so it shows exactly what was packaged. Either way the paths come back with forward slashes, on Windows as much as anywhere else: these are META6 resources keys rather than paths into the filesystem, and the separator is the packaging spec's to choose (0.0.2, 2026-08-12).

A resource that is missing from both fails loudly, naming the path it tried, because the cause is always the same packaging bug: a file on disk that nobody added to META6 resources.

9.1 Never take .basename off a resource

The content-hashed staging has a second consequence, and it is the kind that passes every test and breaks for every user. An installed distribution's MIT.txt is 0E9B31…​.txt on disk and its runtime-third-party.json is A3FCDF…​.json; a checkout's are themselves. Anything that derives a name from a resource's .basename therefore works perfectly in the one place the suite runs and produces nonsense in the one place users are.

Licensing has two such places by nature β€” the pool of licence texts is keyed by the name a row cites, and every runtime row records the data file it came from β€” and both were written that way first. The rule is that a resource's name is its META6 key, never its file's basename; the file is only where the bytes are. xt/04-installed-resources is the check that keeps it true: it installs ariza into a throwaway repository and asks the installed copy for both, because a checkout cannot tell the difference.

10. Cross-platform hygiene

0.0.2 (2026-08-12) is the release where the suite first ran on a real Windows machine, and the ruling it produced is worth stating plainly: every failure was a genuine portability bug rather than a test being fussy, and four of them were in lib/. The fixes are fixture-tested from every platform, which is the standing pattern β€” everything that shells out takes a :&run seam, so the macOS, Linux and Windows branches of each are covered from any one of them.

The three that generalise beyond their own bug:

  • Fall through, do not stop at the first disappointment. sha256-file tries sha256sum, shasum -a 256 and certutil in turn, and absence, an unspawnable binary, a non-zero exit and output holding no digest are all "try the next one"; only an exhausted list dies, naming what every attempt did. A GitHub runner has a shasum on PATH that is a Perl script with no interpreter association, so where finds it and CreateProcess refuses it β€” which took down every digest on the machine while certutil sat untried behind it. There is still no "skip verification" path.

  • Bytes are bytes. .gitattributes declares * -text, because the goldens in t/ are compared with slurp(:bin) precisely so a line-ending difference cannot be shrugged off, and Windows CI images set core.autocrlf=true. Conversion off in both directions also preserves the goldens that are deliberately CRLF β€” the Windows launcher twins and the PowerShell installers, which exist because cmd.exe mis-parses an LF-only batch file in ways that look nothing like a line-ending problem.

  • Containment is a path-identity question, not a byte question. The ELF audit normalises both sides to forward slashes before deciding whether a resolved dependency lands inside the bundle, because a bundled libcrypto.so.3 genuinely inside native/sqlcipher was flagged as a stray purely because one side had backslashes.

11. The Windows runner

A Windows bundle's documented entry point is bin/<exec>.exe: a small C17 program in this repository's runner/ directory that reads bin/<exec>.ariza beside itself, applies that file's environment directives, and starts the bundle's own raku.exe on the app's script with the caller's arguments passed through byte for byte. The .cmd and .ps1 launchers still ship and still work; what changed is which one is documented, put on PATH and exercised by the smoke harness.

11.1 Why a full runner, and not a front door

The cheap version of this is an executable that execs the .cmd. It would have solved nothing.

The first problem is argument fidelity. A batch launcher ends in "%ARIZA_RAKU%" "%BUNDLE_ROOT%\<target>" %*, and %* is not the user's arguments β€” it is the user's arguments after cmd.exe has had a second go at them. ^ is cmd's escape character, %VAR% and !x! are expansions, and a quoted argument is re-split on substitution. An app that takes a prompt, a regex, a path or a snippet of code as an argument receives something the user did not type, and the damage happens inside the substitution where nothing can intercept it. Spawning cmd from an exe keeps every bit of that.

The second is script-execution policy. AppLocker and Software Restriction Policies have a script rule that blocks .cmd and .ps1 files however they are invoked β€” including from a parent process β€” and a locked-down ExecutionPolicy stops the PowerShell twin on its own. Those environments run executables. A trampoline that ends in a script is blocked exactly where the executable was supposed to help.

So the runner does the whole launch itself, with no cmd.exe in it at any point: GetModuleFileNameW for its own path, the directory above bin/ as the root, SetEnvironmentVariableW for the environment, CreateProcessW for the child, and the child's exit code as its own. The argument tail is found by following the C runtime's argv[0] rule exactly β€” leading whitespace skipped, a quoted argv[0] ending at the next quote with no escape processing, an unquoted one ending at the first space or tab β€” and everything after that point is copied into the child's command line unchanged. There is no re-quoting step for anything to be lost in.

Two consequences worth stating: the .cmd and .ps1 remain in the bundle as the transparent alternative (a launcher you can read is worth having, and it is also the way past a SmartScreen prompt), and because the executable is a fixed artefact rather than a rendered script, it can be published once and pinned by digest β€” which a generated script never could be.

11.2 Not Devel::ExecRunnerGenerator

The existing Raku answer to "I want an exe" is Devel::ExecRunnerGenerator, and it is the wrong tool twice over. It ships prebuilt binary blobs from a third party, which is precisely the thing every other component in a bundle is pinned, hashed and inventoried to avoid; and its runner sets a module path and runs a script, with no mechanism for prepending to PATH, which is how Windows finds a bundled DLL and therefore not optional here. Writing ~400 lines of C we own, build in the open and hash-verify is a smaller commitment than depending on someone else's binary for the first thing every Windows user executes.

11.3 The sidecar, and why the config is not baked in

Baking the target script and the environment into the executable at build time would mean one compiled runner per app, per version, per platform β€” i.e. a C toolchain in the bundling path, on every machine, for every cross-build, to produce a file that is otherwise identical for every app. A generic runner plus a text file next to it keeps the executable a fixed, pinned, hash-verified artefact and puts the per-bundle facts in something a user can read, diff and correct.

The sidecar carries the target, the exec name and the display name, and then the environment as ordered directives:

target rakudo\share\perl6\vendor\bin\moneymoor.raku
app-exec moneymoor
app-display Moneymoor
set RAKULIB=inst#{root}\rakudo\share\perl6\vendor
unset PERL6LIB
set NOTCURSES_NATIVE_DATA_DIR={root}\native
prepend-path {root}\native\sqlcipher
prepend-path {root}\native\Notcurses-Native\binaries-notcurses-3.0.17-r11\lib
set DBIISH_SQLCIPHER_LIB={root}\native\sqlcipher\sqlcipher.dll

{root} is the bundle root, resolved at run time, so nothing absolute survives into the file; {{ is a literal brace. The three verbs are set, unset and prepend-path, applied top to bottom.

The prepend operations are deliberately written in reverse priority order: each takes effect immediately, so the resulting PATH begins with the exact Notcurses tag/lib directory, then SQLCipher. That runtime path is a separate, necessary half of the Windows native contract. The PE audit proves that the import-table closure is complete and adjacent; loading the top DLL by absolute path does not make ordinary Win32 dependency search look beside it.

The directives are the ruling worth recording. An earlier draft had sqlcipher_dir and sqlcipher_lib keys and a hardcoded NOTCURSES_NATIVE_DATA_DIR, which put knowledge of every native dependency a bundle might ever carry inside a pinned binary on its own release cadence. Adding a dependency would then have meant a runner release, a checksum commit and a version skew window in which a new bundle could not be launched by an old runner. With directives, the knowledge lives in the renderer beside the .cmd template β€” where it already was β€” and adding a dependency is a line in a template. That is the property ariza needs as native dependencies become app-declared "recipes" rather than a fixed list: a recipe unit can contribute env lines without a sidecar format break, a runner rebuild, or anything else changing.

Two rules keep that generality honest. # only starts a comment in the first non-blank column, because inst#{root}\rakudo\share\perl6\vendor is a real value and an inline-comment rule would truncate it into a RAKULIB that names a relative directory. And an unrecognised directive, or an unknown {token}, is a hard error naming the line rather than something skipped: a bundle whose configuration mentions something the runner cannot do would otherwise start an app with a silently incomplete environment, which fails later, further away, and much worse than not starting.

11.4 The bootstrap ladder

The code that downloads a pinned runner has to exist before the release it downloads can be published, and neither a placeholder binary nor a disabled feature flag is an acceptable way through that. So resources/runner-checksums.txt has exactly two states, and which one it is in decides the behaviour β€” the same precedent as Notcurses-Native's resources/checksums.txt, which falls back to a source build when it lists nothing:

  • No pins recorded. A Windows bundle is built without the executable β€” the .cmd and .ps1 launchers alone, byte-identical to what ariza produced before the runner existed β€” and the build says so once, loudly, naming the file to fill in.

  • Any pin recorded. The ladder inverts. A missing entry for the target architecture, a failed download, or a digest mismatch fails the build. Past that point a bundle without a verified runner is a regression rather than a stage of bootstrapping.

There is deliberately no third rung: no --no-runner, no --skip-verify. An unverified executable staged into a bundle is not a degraded build, it is a different piece of software.

11.5 The artefact pipeline

runner-release.yml builds both lanes in MSYS2 β€” UCRT64 for x86_64, CLANGARM64 for aarch64, the same toolchains that build the notcurses packs a bundle already carries β€” runs the C test suite on the x86_64 lane, and publishes to a GitHub release only on a pushed runner-v* tag, after checking that tag against resources/RUNNER_VERSION. A workflow_dispatch builds and tests and stops, because "validate the recipe" and "overwrite the artefacts every ariza release verifies against" should not be one click apart. That is Notcurses-Native's BINARY_TAG gate in miniature, and it is that shape for the same reason.

MSVC is not a target: one Windows C toolchain story for the whole ecosystem is worth more than a second compiler's warnings. The executable is linked statically and the lane then asserts its import table contains nothing but Windows' own DLLs β€” a libgcc_s_seh-1.dll picked up from the build environment would work on the runner, work on every machine with a toolchain, and fail on a clean install, which is the identical failure shape to the vcruntime140.dll one in Β§3.4.

11.6 Testing something that only runs on Windows

The runner is split so that the interesting half is portable. Config parsing, environment-value construction and the command-line-tail rule are plain C17 over a character type that is wchar_t on Windows and char everywhere else; the win32 API calls live in one file that is not compiled at all on POSIX. ctest therefore says something useful on a Mac or a Linux CI runner: the argv[0] boundary against a table of the cases a batch file damages (^, %VAR%, !x!, embedded quotes, trailing backslashes before a closing quote, unicode, an unterminated quoted argv[0]), the quoting rules, and the sidecar grammar including every way a damaged one has to be refused.

One of those tests parses t/golden/launcher-windows-x86_64.ariza β€” the committed output of the Jinja2 template, compared byte for byte by the Raku suite β€” with the real C parser. The renderer and the reader are in different languages and nothing else connects them; that test is the connection, so a template edit that changes a directive fails in CI rather than on a user's machine.

What POSIX ctest cannot cover is the win32 shell itself. That is the MSYS2 lanes' job to compile and the Windows smoke lane's job to prove: App::Ariza::Smoke runs every {exec} smoke command a second time through bin/<exec>.exe when a bundle carries one β€” in addition to the .cmd run, never instead of it, because the two set the same environment by completely different means and one passing says nothing about the other.

It also automatically calls nc-lib() whenever the manifest declares Notcurses. On Windows, that makes Notcurses::Native's resolver eagerly load the full DLL with its sibling directory and prove the FFmpeg-linked closure. The probe then calls ncvisual_from_rgba and ncvisual_destroy through the core library as a one-pixel operational check. Neither step opens a terminal; notcurses_version would exercise only a core version query.

11.7 Unsigned, and said out loud

The published binaries are not code-signed, so SmartScreen shows its "unrecognised app" prompt the first time a user runs a bundle's .exe. Signing needs a certificate and a signing story ariza does not have today; pretending otherwise in the documentation would be worse than the prompt. The README says so where a user will read it, the release notes say so, and sha256sum -c against the published checksums.txt is what there is in the meantime.

12. Licensing: what a bundle redistributes

A bundle is a binary redistribution. It carries a Rakudo built by somebody else, a MoarVM with a dozen C libraries compiled into it, every Raku distribution in the app's closure, whatever a native pack staged, an SQLCipher taken off the build machine, and on Windows an executable ariza downloaded from its own release page. Until 0.1.0 it said almost none of that: LICENSES/COMPONENTS.md copied the app's LICENSE and Rakudo's, then rendered a table of native libraries from a hardcoded list of filename prefixes.

That table was wrong in two ways at once, and the second is the interesting one. It was factually stale β€” it declared the notcurses pack's FFmpeg a GPL build because of libx264, which that pack had stopped shipping β€” and it was knowledge about other people's software living in ariza's source code, which for a tool that bundles anybody's application is a category error. A stranger's app with a native dependency ariza has never heard of would have got a row saying (unclassified), and the only fix would have been a patch to ariza.

12.1 Data, never code

Everything the merged document says now comes from one of four sources, none of which is a Raku source file, and every row records which one it came from:

  • A native pack's own licensing kit. third-party.json where the pack ships one β€” component rows, filtered to the platform being built β€” and its generated THIRD-PARTY.md where it does not. The pack knows what is in the pack; ariza's job is to read it, not to remember it.

  • resources/runtime-third-party.json. ariza's maintained record of the vendored Rakudo, NQP and MoarVM, the C libraries MoarVM vendors under 3rdparty/, SQLCipher and the runner. These have nowhere else to speak from: they arrive as compiled bytes inside archives with no manifest, so there is nothing in the bundle to interrogate.

  • Each installed distribution's META6.json. The license field, from the repository the app was installed into and from the runtime's own site β€” which is where zef lives, and zef is redistributed like anything else.

  • The app's ariza.toml. Its own row, and rows for what it ships that ariza cannot see.

The schema is deliberately the one Notcurses-Native's third-party.json already uses β€” id, name, version, spdx-license, conveyed-under, license-files, copyright, project-url, source, notes β€” because that format is the interface, not a thing to be reimplemented. ariza adds two fields of its own: kind, which orders the document, and provenance, which is the field a reader checks first, since "who claims this?" should have an answer that is not "the tool".

The 0.2.0 recipe work adds a fifth contributor without a format break: a recipe describes a native dependency, and a native dependency's licensing is rows in exactly that shape.

12.2 The MoarVM rows, and the caveat that comes with them

The libraries under MoarVM's 3rdparty/ are the reason the runtime data file exists. They are not separate files in a bundle β€” they are inside libmoar, which is why no audit of the bundle's contents can find them and why they were invisible before. The set was taken from MoarVM's own .gitmodules and in-tree directories and each licence checked against the LICENSE file of the submodule at the commit MoarVM pins: libuv (MIT, plus BSD-2 for tree.h and ISC for inet_pton), dyncall (ISC), DynASM (MIT), LibTomMath (public domain), cmp (MIT), libatomic_ops (MIT β€” its GPL files are build and test tooling, which MoarVM's own 3rdparty/README.md states are in no built binary), mimalloc (MIT), rapidhash (MIT), zmij (MIT), musl's memmem (MIT, in a directory called freebsd), Steve Reid's SHA-1 (public domain) and msinttypes (BSD-3-Clause).

That set moves between MoarVM releases, and which members are compiled in additionally depends on the platform and on configure flags. So the data file is documented as a superset for the pinned runtime rather than a per-file inventory, and it says so in its own _readme: naming a library that did not end up in the binary costs a paragraph, while omitting one that did is a notice not given. The trigger to re-check it is a change to the [rakudo] pin.

12.3 Fail closed, but only where there is nothing true to say

The asymmetry is deliberate, and it is the same reasoning as the unknown-key-versus-unknown-slug rule in section 8.

A Raku distribution with no license field fails the build. The field exists, the whole ecosystem fills it in, and the fix is one line in a META6.json β€” or, when it is somebody else's distribution, one [[licensing.dists]] row in the app that hit it. An "unknown" row here would be a permanent hole that nobody ever closes. Not that the whole ecosystem does fill it in: a scan of a few hundred installed distributions turned up a dozen with no license at all and a couple saying NOASSERTION, so the check reports every offender in a closure at once rather than the first β€” three unlicensed dependencies should cost one build, not three.

A cited licence text that is nowhere fails the build. A document citing LICENSES/Beerware.txt with no such file is worse than one that never mentioned it.

A native pack with no licensing kit is a visible row and a warning. Here ariza genuinely has nothing true to say: the pack is a third party's artefact and its author has not said. Refusing to build would punish an app for its dependency's packaging, and dropping the pack would hide a redistributed binary β€” so the row exists, says "licensing unknown", names what would fix it, and the build says so out loud. licensing.strict = true promotes it to a failure for a project that will not ship one, which is a decision that belongs to the app and not to the tool.

12.3.1 NOASSERTION: a declaration, and only a declaration

SPDX has a spelling for "somebody looked and could not determine the licensing": NOASSERTION. It is worth having, because the alternative for an app that has genuinely exhausted the search is to invent a licence or to stop shipping β€” and both are worse than saying so. But it is worth having only as a declaration somebody made, which produced four rulings (2026-08-12):

  • Only an app can make it, in a [[licensing.dists]] or [[licensing.third-party]] row. It is never inferred, never a fallback, and never what a missing field decays into.

  • A distribution whose own metadata says NOASSERTION still fails. That is not a declaration anybody made about this bundle; it is the same "nobody has looked" with different spelling, and the fix is for somebody to look and then to say so in the app's config, on the record, with the evidence in notes.

  • An application may not say it about itself. NOASSERTION means a third party's licensing could not be determined; there is nobody to look on behalf of the thing being built here.

  • licensing.strict refuses it. Strict is the statement that this bundle ships nothing it cannot name, and "we could not find out" is not a name. That is the same rule the unattributed-pack check enforces, applied to the other way a bundle can carry something nameless β€” which is what makes strict a single coherent promise rather than two half-checks.

No licence text is looked up for such a row (there is none). Instead the row carries a generated sentence β€” not the app's own wording β€” saying that licensing was not asserted and pointing at the component's own repository, so the reader of a bundle is told the same thing every time. ariza-manifest.json counts them in licensing.noassertion, apart from spdx-ids, because a gate reading the identifier set must never see NOASSERTION sitting in it looking like a permissive licence.

The licence-text set moved at the same time and for the same reason: a scan of a real closure turned up live needs for AGPL-3.0, X11 and OFL-1.1, so ariza now ships sixteen texts rather than thirteen, and a test enumerates the set so that adding or losing one is a decision rather than a side effect.

The one place this pragmatism is visible in the code is the fallback for a pack that ships THIRD-PARTY.md but no third-party.json β€” which is every pack that exists today, since the upstream generator writes the document into the archive and keeps the manifest in its repository. Parsing generated prose is not a thing to do casually, so the parser requires the Summary table's exact generated header, takes only the four columns and the two labelled Details bullets it understands, and returns nothing at all for a document in any other shape β€” falling through to the unattributed row rather than to rows assembled out of the wrong columns.

12.4 Determinism, and the manifest summary

Rows are ordered by kind, then by folded name, then by id β€” never by the order a directory happened to be read in β€” and nothing in the document is a timestamp or a path from the build machine. Two builds of the same inputs produce byte-identical output, which is what makes it diffable across releases and what makes the golden test meaningful. Licence texts are deduplicated by filename with a fixed priority; two sources offering the same name with different bytes warn, naming both, and the higher-priority copy is the one that ships, because the alternative is a MIT.txt that depends on directory order.

The priority is the app's copy, then a pack's, then ariza's own (ruled 2026-08-12, after an initial implementation had it the other way round). Nearest-to-the-software wins, and the deciding case is the SIL Open Font License: an OFL text carries a Reserved Font Name, filled in per font, so the file an app names in license-files is a different document from the template rather than a formatting variant of it β€” and shipping the template instead would replace a real notice with a placeholder that says <year> <owner>. The same argument holds one step down: a pack's LICENSES/MIT.txt is the notice its author shipped beside the binaries, while ariza's is the SPDX template. So the generic text is what to fall back to when nobody supplied one, never what to prefer over one somebody did.

ariza-manifest.json gains a licensing object β€” row count, how many were unattributed, and the sorted set of SPDX identifiers the bundle is conveyed under. That is the thing a release gate downstream can test without parsing prose: unknown > 0 means something is unattributed, and the identifier set is where a copyleft component that arrived inside a native pack becomes visible to a policy that cares.

12.5 The runner's manifest gap, closed here

The same pass closed a smaller hole. components.runner did not exist, so bin/<exec>.exe β€” the file a Windows user actually runs β€” was the only artefact in a bundle with no URL and no digest recorded anywhere, while the runtime archive and SQLCipher both had one. App::Ariza::Runner.stage now returns what it staged rather than only where it put it, App::Ariza::Launcher.write carries that out with the paths it wrote, and the manifest records the artefact name, the release tag, the download URL and the digest the copy was verified against. Everything a bundle downloads is now traceable to something published.

Timeline

ReleaseDateThe decision it carried
0.0.12026-08-12One distribution mechanism: the legacy installer machinery retired, bundles only.
0.0.22026-08-12Windows portability is a lib/ concern, not a test concern; digest fallthrough, * -text, separator normalisation.
0.0.32026-08-12One clean-machine installer smoke per platform, not just Linux.
0.0.42026-08-12The bundled zef is invoked by what the file is, not by what the platform is (zef.raku under the bundled raku on Windows).
0.0.52026-08-12A {raku} smoke command gets the launcher's environment, including the Windows PATH that resolves a DLL by name.
0.0.62026-08-12PE gets the same dependency walk ELF and Mach-O get.
unreleased2026-08-12The redistributable gate: an MSVC-built dependency is a clean-machine failure, so the CI lane takes UCRT and the audit refuses the alternative.
unreleased2026-08-12Windows launches from a compiled runner of ariza's own, pinned by digest; its sidecar carries ordered env directives, so a bundle's dependencies are the renderer's business and never the executable's.
unreleased2026-08-13The install pays the first launch: the installers warm the app up under a visible line, and a warm-up that fails warns rather than failing an install that has already been verified.
unreleased2026-08-13Weekly managed-update prompts are generated launcher policy: exact stable GitHub tags, three persisted choices, transactional exact-candidate install, exact-predecessor retention, and a nonce-authenticated one-hop handoff (runner-v2 on Windows).
0.2.22026-08-14Windows native correctness is closure plus search: PE audit proves adjacency, launchers put the exact Notcurses lib before SQLCipher on PATH, and clean smoke invokes the full-library resolver before a TTY-free core visual operation.
unreleased2026-08-12A bundle says what it redistributes, out of four sources it reads rather than a table it remembers; a Raku distribution with no licence fails the build, an unattributed native pack is a visible row and, on request, a failure.
unreleased2026-08-12NOASSERTION is a declaration an app makes after looking, never a gap ariza fills: not from a distribution's own metadata, not about the app itself, and not at all under licensing.strict.

App::Ariza v0.2.4

bundler and distribution tool for Raku terminal apps

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Config::TOML:ver<0.1.3+>:auth<zef:raku-community-modules>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Template::Jinja2:ver<0.3.0+>:auth<zef:apogee>

Test Dependencies

Provides

  • App::Ariza
  • App::Ariza::Bundle
  • App::Ariza::CI
  • App::Ariza::Config
  • App::Ariza::Installer
  • App::Ariza::Launcher
  • App::Ariza::Licensing
  • App::Ariza::Native
  • App::Ariza::Platform
  • App::Ariza::Rakudo
  • App::Ariza::Resources
  • App::Ariza::Runner
  • App::Ariza::Site
  • App::Ariza::Smoke
  • App::Ariza::Tools
  • App::Ariza::Update
  • App::Ariza::Versions

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite β€” the markup and publishing tools behind this site.