Repository navigation
Spike: can an ad-hoc-signed Nuitka bundle replace itself on macOS 15+? #77
Description
Why this is a spike and not a task
#74 proposes in-app updates and names Velopack as the first candidate. Velopack's
own macOS documentation says code signing and notarization are required before
shipping releases, and its installer page says the .pkg must be signed. This
project ships unsigned by decision.
That decision either survives an updater or it does not, and nobody knows which.
The answer is worth an afternoon of measurement and nothing else should be built
until it exists — including the choice between Velopack and Sparkle, which
depends on it.
Deliverable: a written answer in the issue. No code lands from this work.
What is already established
Measured on the built bundle and both published release archives:
codesign -dv Signature=adhoc, TeamIdentifier=not set
codesign --verify fails (a subcomponent is not signed at all)
spctl --assess rejected, "no usable signature"
Three things argue that a self-replacing update might work anyway, and they are
what makes this worth measuring rather than assuming:
- A self-download sets no quarantine. Only quarantine-aware applications —
browsers, mail clients — attachcom.apple.quarantine. A bundle the
application fetches itself should launch with no Gatekeeper assessment at all.
That makes the first install the barrier, not the update. - Apple Silicon accepts an ad-hoc signature. Every arm64 executable must
carry at least one or the kernel refuses it, and Nuitka's output has one. A
whole replacement bundle should therefore stay runnable — which is also why
patching files inside an installed bundle is not an option, as it would
invalidate the signature of something already on disk. - Windows has no equivalent gate; unsigned installs work and the user sees
SmartScreen, exactly as they do for today's ZIP.
Questions to answer, each with evidence
- Does a running MatteLoop bundle successfully replace itself with a newer
ad-hoc-signed bundle and relaunch, on macOS 15 and on a current macOS? - Does Velopack's macOS flow function at all with nothing signed, or does
installerrefuse an unsigned.pkgoutright? - App Translocation: an app launched from
~/Downloadswithout being moved
in Finder runs from a read-only randomised mount. Velopack claims it can update
a moved bundle; a translocated one is not moved. What happens? - Does replacing the bundle require elevation when it lives in
/Applications,
and what does the user see? - Can
velopackbe frozen by Nuitka, and canApp().run()— documented as
needing to be the first thing in the main process — coexist with an entrypoint
that already has one?packaging/entrypoint.pyintercepts the resource-tracker
payload andapp.py:maincallsmultiprocessing.freeze_support()before
argparse. Every spawned child re-enters the same binary, and
SetAutoApplyOnStartupdefaults to on, so a segmentation child spawning
mid-render could apply a pending update. Is that reachable, and can it be
disabled from the Python SDK?
What the answer decides
- If a self-replacing unsigned update works: the updater track stays
possible, and the next question is what package authentication means without
signatures. - If it does not: an updater costs an Apple Developer ID at USD 99/year plus
a Windows signing identity, and the cheaper alternatives in feat: add automatic app updates with delta downloads and full-package fallback #74 — a version
check that opens the releases page, or package-manager publication — become
the answer rather than a fallback.
Note that this needs a real macOS host. docs/building.md records that the
project has never launched the application on macOS 15 at all, which is its own
finding.
Refs #74
Activity
smb commentedon Sep 6, 2026
Measured answer
Host: macOS 26.6.2, arm64. No macOS 15 host was available, so every result below
is from a current macOS only.
Headline: an update does not re-prompt. The barrier is the first install and
only the first install, which is what the spike needed to know.
A blocker that had to be cleared first
The published
MatteLoop-v0.2.1-macos-15-arm64.zipcannot be launched at all:Every entry in that archive is
-rw-r--r--, includingContents/MacOS/matteloop.That is the defect reported in #57, and PR #70 landed after the v0.2.1 tag, so
the archive currently offered for download is inert. Exactly one file in the
bundle needs the executable bit; restoring it made the build run
(
MatteLoop 0.2.1).Does a bundle replace itself and relaunch?
Yes, in both variants, measured against a real installation in
/Applicationsthat had been approved by the user once:
rm -rfthe bundle under the running processopen(LaunchServices)The replacement was demonstrably a different build: the main executable changed
size and digest. It launched with no Gatekeeper interaction of any kind.
Why. Gatekeeper assesses only bundles carrying
com.apple.quarantine. Theapproved installation carries one with the assessment bit set — that is where
the user's one-time consent is recorded. A bundle written by the application
itself carries no such attribute at all, because only quarantine-aware
downloaders set one. No attribute, no assessment. The consent is not inherited;
the question is simply never asked. The ad-hoc signature changing, and
codesign --verifycontinuing to fail on an unsigned subcomponent, are bothirrelevant at that point because nothing inspects them.
First install
Confirmed as expected, and worse than the issue assumed. With a genuine
quarantine attribute the launch is refused, and the dialog offers only Done
and Move to Bin — there is no "Open Anyway" and no Control-click path.
spctl --assessreportsrejected, source=no usable signaturethroughout.App Translocation
One run with an already-assessed quarantine flag did launch, and it ran
translocated from a read-only
AppTranslocationmount. In that state theapplication sees itself at a path it cannot write, so self-replacement is
impossible there. This did not reproduce on demand, so it stands as an
observation rather than a measurement — but it is enough to make the
consequence concrete: an application left in the download folder cannot update
itself. The install instruction must require moving it to
/Applications.Relevant to #76.
Elevation
/Applicationsisdrwxrwxr-x root:admin. An admin user replaces a bundlethere with no elevation and no password prompt.
Does Velopack function unsigned?
It does.
vpk1.2.0 packaged the real bundle with warnings only, never arefusal:
It produced the full set —
.nupkg, portable.zip,Setup.pkgand therelease feed. Two details worth recording:
.appit emits runs, is ad-hoc signed, and has a correct executable bit..pkghasStatus: no signatureandspctl --assess --type installrejects it. The installer route is therefore the weaker of the two; in-app
updates should replace the bundle, not run a package.
Can the Python SDK be used, and can auto-apply be disabled?
velopack1.2.0 is on PyPI as an abi3 wheel with a macOS arm64 build — a singlecompiled extension module, which Nuitka handles as an ordinary binary
extension.
App().run()returns normally when the process is not a Velopackinstallation; it logs
NotInstalledand execution continues. It thereforecoexists with an entrypoint that already calls
multiprocessing.freeze_support()before argparse.
set_auto_apply_on_startup(False)is exposed on the builder, so the concernabout a segmentation child applying a pending update mid-render is
controllable from Python.
Still unanswered
What this decides
The unsigned track survives. Signing is not what makes updates work — updates
work without it. Signing buys a smoother first install, which is a separate
decision with a separate value, and no longer blocks the updater work in #74.
smb commentedon Sep 6, 2026
Conclusion: the updater track is open
Working assumption for this answer: releases stay unsigned for the foreseeable
future, and the user approves the application once at first install through
System Settings. Under that assumption the spike's blocking question is
answered — we can start.
Full updates: yes, measured
Replacing the whole bundle and relaunching works, with no dialog and no
elevation, including replacing it underneath the running process. That is the
entire mechanism: download, verify a checksum, unpack beside the installed
bundle, swap, relaunch.
One correction to the assumption
The install location is not interchangeable. An application left where it
was downloaded runs translocated from a read-only mount, where it cannot write
its own bundle and therefore cannot update itself at all.
What ends translocation is the user moving the application in Finder;
/Applicationsis simply the natural destination. The install instructions haveto require that move, or the updater is inert for anyone who skips it. Refs #76.
Delta updates: sound in principle, payoff unmeasured
The concern recorded earlier — that patching would invalidate the signature —
applies only to patching a bundle already on disk. A delta does not mutate, it
reconstructs: base plus patch produces a byte-identical result, so the new
executable carries exactly the ad-hoc signature its full build produced. Nothing
needs re-signing, provided the update is assembled beside the installed bundle
and swapped in whole, which is what the full-update path already does.
What is genuinely unknown is whether deltas are worth anything here. The main
executable is roughly 79 MiB of a ~380 MiB installed tree, and Nuitka output
moves around between builds. A delta between two real releases could be a few
MiB or most of the binary. That measurement is an hour of work and it decides
the question below.
Velopack, or a few lines of our own
vpkneeds .NET in CI.pkgis rejected by the install assessment, so unusableVelopack earns its place only if the delta measurement comes out well. If it
does not, it buys a .NET dependency for nothing, and the self-written swap is
the better answer.
What staying unsigned actually costs
Not installability — authenticity. Without signatures the integrity of an update
rests entirely on HTTPS plus a published checksum, and an updater is by
definition a mechanism that downloads and executes. Whatever is chosen, the
package itself should carry its own signature — minisign with the public key
compiled into the binary is cheap — so the trust rests on a key rather than on
the transport.
Suggested order
smb commentedon Sep 9, 2026
Second measurement pass: translocation is now measured, not observed
Re-run on macOS 26.6.2 arm64 against the two published archives, to close the
three gaps the earlier answer left open. No macOS 15 host is available, so that
one stays open.
App Translocation is reproducible, and it changes the design
The earlier answer recorded translocation as a single observation. It now
reproduces on demand for a quarantined copy launched through LaunchServices,
and the consequence is measurable rather than inferred:
translocated process runs: succeeds.
fails with
Read-only file system.So the earlier conclusion — that an app left in the download folder cannot
update itself — is half right. What fails is a design that replaces "whatever
path the running process reports". An updater that resolves and keeps the
physical install path can replace the bundle even from a translocated process.
That is a requirement on the updater, not only an instruction to the user.
Also confirmed: a quarantined copy under
~/Applicationstranslocates too.Living in an Applications folder is not the cure; the Finder move that clears
the quarantine attribute is.
Self-replacement, re-confirmed
Wait-for-exit, rename aside, rename into place, relaunch — works at a
user-writable physical path, both by direct executable path and through
open -a, with no dialog and no elevation.~/Applicationsisdrwxr-xr-xfor the owner;/Applicationsisdrwxrwxr-x root:admin, so anadmin needs no elevation there either (permissions read, not written to).
Quarantined first launch
Unchanged and confirmed:
spctl --assessreportsrejected, no usable signature; a direct execution of a quarantined copy does not return.One packaging defect worth repeating
The published
MatteLoop-v0.2.1-macos-15-arm64.zipstill cannot be launched asunpacked — every entry is
-rw-r--r--, including the main executable. That is#57/#70, fixed after the v0.2.1 tag; 0.3.0 is correct. It only matters here
because any A → B qualification using 0.2.1 as the base must normalize that bit
first, or it measures the wrong failure.
Still unanswered
shell helper and
kill -TERM, not the application's own close path and notVelopack's apply helper — and it packed only the main executable, not the
full bundle. Neither substitutes for the qualification run.
hand, which measures Gatekeeper's response but not the download agent.
smb commentedon Sep 11, 2026
Answered, and the answer has been load-bearing ever since. Closing.
The spike's deliverable was explicitly "a written answer in the issue. No code lands from this work." That answer is in the comments above, in two measurement passes, and the question it blocked — whether an unsigned, ad-hoc-signed Nuitka bundle can replace itself — came back yes.
What it established, in short:
All of this shipped: in-app updates are in 0.4.0 and 0.4.1 and work.
One gap stays open and is worth recording rather than quietly dropping. No macOS 15 host was ever available, so every measurement here was taken on macOS 26. The project supports macOS 15+. Two releases have now shipped and updated without a report from that version, which is evidence but not a measurement. If a macOS 15 machine ever becomes available, re-running the swap on it is an afternoon well spent — as its own issue, not by reopening this one.