Smoke
NAME
App::Ariza::Smoke - prove a built bundle works somewhere it has never been
SYNOPSIS
use App::Ariza::Smoke;
my %r = App::Ariza::Smoke.smoke(:archive($tarball));
say %r<passed>; # True
for %r<checks> -> %c {
say "{%c<ok> ?? 'ok ' !! 'FAIL'} {%c<name>}: {%c<detail>}";
}
# ok unpack: extracted to moneymoor-0.2.0-macos-arm64
# ok manifest: App::Moneymoor 0.2.0 for macos-arm64
# ok launcher: bin/moneymoor
# ok runtime: rakudo 2026.07-01
# ok target: rakudo/share/perl6/vendor/bin/moneymoor.raku
# ok precomp: 395 precompiled artefacts ship with the bundle
# ok precomp-relocatable: 2938 dependency records, all repository-relative
# ok native-audit: 32 native binaries have bundled dependency closure
# ok notcurses-load: {raku} ā exit 0: full libnotcurses resolver and core visual passed
# ok smoke[0]: {exec} ā exit 0: App::Moneymoor 0.2.0
# ok smoke[1]: {raku} ā exit 0: ok: encrypted database created ā¦
DESCRIPTION
A build that finishes is not a build that works. smoke takes the
archive, unpacks it somewhere the build never touched, and runs it the
way a stranger would.
The scratch directory has a space in its name
On purpose. 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.
The environment is replaced, not filtered
run's :env substitutes the child's whole environment, which is
env -i without needing env. Commands get PATH=/usr/bin:/bin,
HOME and TERM. Windows gets only SystemRoot\System32 on PATH,
plus SystemRoot, ComSpec, PATHEXT and the other small set without
which processes cannot start ā never the runner image's MSYS2/vcpkg/Visual
Studio toolchain PATH.
That is the entire point. The failure this catches is a bundle that
quietly relies on 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.
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. It is 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, and on Windows a PATH prepended with the
exact Notcurses tag/lib directory followed by SQLCipher's directory, since
that is how their dependent DLLs are found.
DBIISH_SQLCIPHER_LIB is set on both, exactly as the launcher templates
set it. Without any of that it would be testing the absence of a
launcher rather than the bundle.
Placeholders
Smoke commands come from the bundle's own ariza-manifest.json, so an
archive can be checked with no access to the app's repository. Each argv
word is expanded against:
{exec}ā the launcher{raku}ā the bundled interpreter{site}ā the module repository, wherever the manifest says the bundle put it{native}ā the staged native libraries{bundle}ā the bundle root{tmp}ā a writable scratch directory beside the unpacked bundle
Nothing goes through a shell, so a smoke command can contain an entire
Raku program without a quoting layer to get wrong ā which is what makes
it reasonable for an app to smoke-test its database engine and not just
--version. See App::Ariza::Config for how to write one.
The checks
unpack ā the archive extracts to exactly one directory.
manifest ā
ariza-manifest.jsonis present, readable, and the right shape: it names an app, and every entry indistsis an object with a name. That last one is not paranoia. A Raku{ }literal whose body starts with a.methodcall 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.launcher ā
< bin/<exec> >exists, and is executable where that is a question anyone can answer: it takes both a POSIX bundle to have an executable bit and a POSIX host to read one. The platform comes from the manifest rather than from the machine ā that is what lets an archive be checked anywhere ā so a Windows box inspecting a Linux bundle is ordinary, and there the bit is absent from the filesystem rather than absent from the bundle.runner ā Windows only:
< bin/<exec>.exe >and its< bin/<exec>.ariza >sidecar. Their absence passes and says so ā a bundle built before the first runner release was pinned launches from its.cmdand is not broken ā but an executable with no sidecar to read is a bundle that cannot start, so the pair is checked together.runtime ā the bundled interpreter is there.
target ā the script the launcher execs is there.
precomp ā the precompilation store shipped warm. An empty one is not a crash, so nothing else would catch it; it just makes every launch slow, forever, on a read-only bundle.
precomp-relocatable ā and that store is worth something somewhere else. Every dependency a compiled unit records has to name a repository (
vendor#sources/ā¦) rather than a place (/home/runner/work/ā¦/sources/ā¦), because Rakudo resolves the first wherever the bundle now is and throws the unit away when it cannot find the second. This is the one check here that cannot be replaced by running the thing: on the machine that built the bundle those paths still exist, so a bundle that will recompile its whole closure for every user passes every other check in this list, including the smoke commands.native-audit ā App::Ariza::Native's audit, re-run over the unpacked tree rather than the build directory, because that is the tree a user has.
notcurses-load ā when the manifest declares Notcurses, first calls
nc-lib. On Windows, Notcurses::Native's resolver eagerly loads the full library withLoadLibraryExWand its sibling dependency directory, so the FFmpeg-linked closure is genuinely exercised. It then creates and destroys a one-pixel visual through the core library as an operational check. Anotcurses_versionquery would prove only a version binding; no terminal or Notcurses context is opened here.smoke[n] ā each declared command, in order.
smoke[n].exe ā on Windows, each
{exec}command again through< bin/<exec>.exe >, when the bundle carries one. In addition to the.cmdrun, never instead of it: the script and the executable implement one contract by two completely different routes ā batchsetagainstSetEnvironmentVariableW,%*against a verbatim command-line tail ā so a passing script says nothing at all about the executable, and the executable is the one a user will actually run.
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; deleting it means reproducing a
build to see anything.
METHODS
smoke(:$archive!, :$work-dir, :$keep, :$verbose --> Hash)
{ passed, checks, dir, root, manifest, kept }. Each check is
{ name, ok, detail }, and command checks also carry output.
base-env(--> Hash) / runtime-env(IO() $root, %manifest --> Hash)
The replacement environment, and the launcher-equivalent additions.
notcurses-lib-dir(IO() $root, %manifest --> IO::Path)
The exact staged Notcurses library directory recorded by the manifest, with the tag-based derivation retained for older manifests.
notcurses-probe-program(--> Str)
The terminal-free Raku program used by notcurses-load. It explicitly
calls nc-lib first, then creates and destroys a one-pixel visual through
the core library without initializing a terminal or a Notcurses context.
Exposed so call order and the no-terminal contract can be inspected without
executing a native library in a unit test.
site-dir(IO() $root, %manifest --> IO::Path)
The module repository inside an unpacked bundle, taken from the
manifest's launcher.site so that an archive is checked on its own
terms ā including one built before that repository moved into the
runtime's vendor prefix, which recorded nothing and kept its modules
in < <root>/site >.
scratch-dir(:$work-dir --> IO::Path)
A fresh directory with a space in its name.
AUTHOR
Matt Doughty
COPYRIGHT AND LICENSE
Copyright 2026 Matt Doughty
This library is free software; you can redistribute it and/or modify it under the Artistic License 2.0.