Skip to content

Make the macOS disk image run, and say what macOS will do about it - #11

Merged
jaydg merged 9 commits into
jaydg:mainfrom
culmat:macos-release-fixes
Sep 19, 2026
Merged

jaydg merged 9 commits into
jaydg:mainfrom
culmat:macos-release-fixes

Conversation

@culmat

@culmat culmat commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

The macOS disk image from a recent CI run could not run at all on the Mac
I tried it on, for two separate reasons, and a third made it unpleasant
on every Mac. Taking them from worst to least:

1. The image is arm64 only. macos-latest is Apple silicon now, a
Mach-O holds one architecture, and so do the Homebrew libraries
dylibbundler puts beside it. On an Intel Mac:

-bash: /Applications/Avanor.app/Contents/MacOS/avanor: Bad CPU type in executable

Rosetta translates Intel code for Apple silicon, not the other way, so
there is nothing the user can do about it.

2. It is built for macOS 26. otool -l reports minos 26.0 on the
binary and all three libraries, while Info.plist promised
LSMinimumSystemVersion 10.13. Homebrew bottles are built for the OS of
the machine that installed them, so macos-latest quietly raises the
floor with every macOS release. Anyone below 26 got dyld failing over a
library name rather than LaunchServices saying which macOS is needed.

Between them, the published image ran only on Apple silicon Macs on
macOS 26 or newer, and nothing said so.

3. Nothing is signed, so Gatekeeper refuses it:

$ codesign -dvvv /Applications/Avanor.app
/Applications/Avanor.app: code object is not signed at all
$ spctl -a -vvv -t exec /Applications/Avanor.app
rejected  (source=no usable signature)

The binary and libraries carry only the ad-hoc signatures the linker and
install_name_tool add by themselves, which say nothing about who built
them. And macOS 15 withdrew the Control-click shortcut, so System
Settings is now the only way through - which the window did not mention.

What is here

Two disk images, one per architecture, built on pinned runners rather
than macos-latest so the minimum macOS is a decision rather than a
side effect, with LSMinimumSystemVersion read from the binary. A CI
step asserts each runner produced the architecture its half of the
matrix is named for, so a label that changes underneath us fails loudly.

A backdrop in the image window saying what to do with the icons and, in
the unsigned case, what Gatekeeper is about to say and where to go. It is
generated by resources/make-background.sh from the game's own colours,
the way make-icon.sh generates the icons, and committed beside them.
Two variants, so a signed image never carries a warning that no longer
applies to it.

Signing and notarisation for both macOS and Windows, switched on by
secrets. With no secrets set - which is every fork and every pull
request, including this one - the build produces precisely the unsigned
artifacts it produces today.
There is no second path to keep working:
the signing collapses to :. Nothing here obliges you to spend anything;
if you do, SIGNING.md says what and how, and it starts signing without
another change.

resources/avanor-launcher is gone. The game is now the bundle's own
executable and opens Terminal itself when it finds it was started without
one. The script left the executable macOS approves and the executable
that runs as two different files, and a lone Mach-O can hold no
notarisation ticket - so it was assessed separately, and that assessment
needs the network. Approve Avanor, go offline, open it again, and it
would not have started.

What I checked

On an Intel Mac, macOS 15.7.9: the x86_64 image builds, installs, and the
command that used to print Bad CPU type now runs. LSMinimumSystemVersion
reads 15.0 and matches minos. Icons land exactly where create-dmg is
told to put them. ./.github/run-tests.sh passes, soak included. Opening
the installed app through LaunchServices puts the game under Terminal as
intended.

The signing sequence I could only exercise ad-hoc, having no Developer
ID. It completes and codesign --verify --deep --strict passes. Two
things that came out of trying:

  • The entitlements have to go on the bundle, not on the game inside it.
    Signing a bundle signs its executable too, and doing the executable
    first left something that verified perfectly with no entitlements on it
    at all. That bug is fixed here; it would have shipped silently.
  • Ad-hoc signing cannot get past library validation under the hardened
    runtime, because an ad-hoc signature has no team for the libraries to
    match. A real Developer ID signs all four with one team and the problem
    does not arise. SIGNING.md warns about this so nobody debugs it twice.

One thing I could not settle: resources/avanor.entitlements carries
both allow-jit and allow-unsigned-executable-memory because the
LuaJIT Homebrew ships maps anonymous pages and turns them executable with
mprotect rather than using MAP_JIT. On x86_64 the game runs with or
without them, so the test proves only that they are accepted and harmless.
arm64 enforces this far more strictly and is where it would matter. Worth
a look on Apple silicon before the first signed release - the symptom
would be sluggishness rather than a crash, because the refusal stops the
compiler and not the game.

Not done

Signing the avanor.exe inside the NSIS installer, as opposed to the
installer itself, needs a two-stage submit to SignPath. Left as a
follow-up.

The disk image was built on macos-latest, which is Apple silicon, and a
Mach-O holds one architecture - as do the Homebrew libraries dylibbundler
puts beside it. An Intel Mac got "Bad CPU type in executable", because
Rosetta translates x86_64 to arm64 and not the other way about.

Build on a runner of each kind instead and name each image for what it
holds. The runners are pinned rather than macos-latest for a second
reason: every Homebrew bottle is built for the OS of the machine that
installed it, so the runner decides the lowest macOS a release can run
on, and macos-latest moves that floor with every new macOS.

That floor was not being told either - Info.plist claimed 10.13 while the
binary asked for 26.0, so an older Mac got dyld failing over a library
rather than LaunchServices naming the macOS it needs. Read it from the
binary's LC_BUILD_VERSION and write what is true.
The window showed the application, a folder to drag it into and three
documents, and said nothing. What actually happens on a double click is
that Gatekeeper refuses the application, because nothing here is signed,
and macOS 15 withdrew the Control-click shortcut that used to get past
that - System Settings is now the whole of the way through. Somebody
meeting that has had no warning and no instructions.

Draw a backdrop that gives them both: what to do with the icons above,
and underneath, what the refusal will look like and where to go. Two of
them, so that a signed image - when there is one - does not carry a
warning that no longer applies.

The picture is generated rather than drawn, from the game's own colours,
the way resources/make-icon.sh generates the icons, and committed beside
them.

The icons moved right to make this work. Finder will not place an icon
within 114 pixels of the left edge, and it shifts every icon in the
window rather than clamp the one - so the README at 70 was quietly
sliding the whole layout 44 pixels out from under the backdrop.
The bundle's executable was a shell script beside the game, which opened
Terminal on it. That left the executable macOS approves and the
executable that actually runs as two different files, and the second one
is a plain Mach-O: nothing that can hold a notarisation ticket. So it was
assessed on its own, and a lone Mach-O can only be assessed by asking
Apple over the network - approve Avanor, go offline, open it again, and
it would not start.

Move that into the game, behind __CFBundleIdentifier, which LaunchServices
sets to the bundle it is starting and a shell never sets at all. Finder
now runs the same file it approved, and the script is gone.
Nothing in the image is signed, so Gatekeeper refuses it. Add the signing
and notarisation, switched on by CODESIGN_ID and NOTARY_PROFILE: with
neither set - a fork, a pull request, anybody's own machine - this builds
precisely the unsigned image it built before, because the signing
collapses to ":" rather than into a second path that has to be kept
working. A key can be added later without touching any of this again.

Two entitlements, because notarisation means the hardened runtime and
under it LuaJIT may not run what it compiles. allow-jit covers MAP_JIT,
which the LuaJIT Homebrew ships does not use; allow-unsigned-executable-
memory covers what it does. Both, so it keeps working either way.

The entitlements go on the bundle rather than on the game inside it:
signing a bundle signs its executable too, and doing the executable
first left a signature that verified perfectly and had no entitlements
left on it at all.

Signed and notarised twice, application and image. The image's ticket
stays behind when the application is dragged to /Applications, and only
the one stapled into the application is there when it is opened offline.
Imports the certificate into a keychain of the run's own and tells the
Makefile what to sign with. The step is skipped when the certificate
secret is empty, which is every fork and every pull request - secrets do
not reach those - and make then builds the unsigned image as before.

The certificate is tested through an environment variable rather than in
the `if:` directly, because a secret cannot be read from one.
The installer is unsigned, so SmartScreen calls it an unknown publisher.
SignPath signs open source projects for nothing, which Avanor is, and it
signs from an artifact of the run rather than from a key on the runner -
their hardware holds the certificate and it never comes here - so the
installer is uploaded first and the signed copy replaces it.

Switched on by the same kind of check as the disk images: no token, no
signing, and the unsigned installer goes on the release as before.

Worth saying plainly: this will not make the SmartScreen prompt go away
at once. Since 2024 a certificate no longer buys an exemption, only a
reputation that accrues as people download. What it does buy immediately
is a name in the prompt instead of "unknown publisher".
The README explained how to build from source and nothing else, so
somebody who had downloaded a package had nowhere to look - not for which
of the two disk images is theirs, and not for what to do about the
refusal the unsigned one produces.

SIGNING.md is for whoever publishes the releases rather than for whoever
builds them: what the accounts cost, how to get the certificate and the
notarisation key, and which secret each one becomes. The machinery it
describes is already in the Makefile and in CI and does nothing until
those secrets exist, so this is the missing half of it.
Making the game the bundle's executable had a consequence on the
unsigned path that only showed up on a download. The linker leaves an
ad-hoc signature on the executable whatever we do, and that executable is
now the bundle's own - so a bundle with nothing sealing it is a bundle
whose signature claims resources it does not have:

  code has no resources but signature indicates they must be present

macOS can put that to the user as "Avanor is damaged and can't be
opened", which is alarming, untrue, and offers no way forward - where the
refusal we want says the developer is unidentified and can be got past in
System Settings, which is what the README describes.

So seal it either way, and vary what with: a Developer ID with the
hardened runtime and the entitlements when there is one, an ad-hoc seal
when there is not. Gatekeeper still refuses the ad-hoc one, which is
right, but it refuses it as what it is.

No hardened runtime on the ad-hoc path: it turns on library validation,
which wants the libraries and the executable to share a team, and an
ad-hoc signature has none - the three bundled libraries would not load.
@jaydg
jaydg merged commit 1da393d into jaydg:main Sep 19, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants