Make the macOS disk image run, and say what macOS will do about it - #11
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-latestis Apple silicon now, aMach-O holds one architecture, and so do the Homebrew libraries
dylibbundlerputs beside it. On an Intel Mac: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 -lreportsminos 26.0on thebinary and all three libraries, while
Info.plistpromisedLSMinimumSystemVersion 10.13. Homebrew bottles are built for the OS ofthe machine that installed them, so
macos-latestquietly raises thefloor 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:
The binary and libraries carry only the ad-hoc signatures the linker and
install_name_tooladd by themselves, which say nothing about who builtthem. 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-latestso the minimum macOS is a decision rather than aside effect, with
LSMinimumSystemVersionread from the binary. A CIstep 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.shfrom the game's own colours,the way
make-icon.shgenerates 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.mdsays what and how, and it starts signing withoutanother change.
resources/avanor-launcheris gone. The game is now the bundle's ownexecutable 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 typenow runs.LSMinimumSystemVersionreads 15.0 and matches
minos. Icons land exactly wherecreate-dmgistold to put them.
./.github/run-tests.shpasses, soak included. Openingthe 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 --strictpasses. Twothings that came out of trying:
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.
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.mdwarns about this so nobody debugs it twice.One thing I could not settle:
resources/avanor.entitlementscarriesboth
allow-jitandallow-unsigned-executable-memorybecause theLuaJIT Homebrew ships maps anonymous pages and turns them executable with
mprotectrather than usingMAP_JIT. On x86_64 the game runs with orwithout 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.exeinside the NSIS installer, as opposed to theinstaller itself, needs a two-stage submit to SignPath. Left as a
follow-up.