Skip to content

fix macOS signing - #1820

Merged
christianparpart merged 6 commits into
masterfrom
fix/macos-signing
Aug 4, 2026
Merged

fix macOS signing#1820
christianparpart merged 6 commits into
masterfrom
fix/macos-signing

Conversation

@christianparpart

Copy link
Copy Markdown
Member

again and again 🗡️

@github-actions github-actions Bot added frontend Contour Terminal Emulator (GUI frontend) CMake labels Dec 13, 2025
@christianparpart christianparpart added the no changelog Tells the CI to not require a changelog entry label Dec 13, 2025
@github-actions github-actions Bot added the CI GitHub Actions & CI label Dec 13, 2025
@christianparpart
christianparpart force-pushed the fix/macos-signing branch 2 times, most recently from 16c506e to 68c89a2 Compare December 14, 2025 07:55
@github-actions github-actions Bot removed the CI GitHub Actions & CI label Dec 14, 2025
@christianparpart
christianparpart force-pushed the fix/macos-signing branch 3 times, most recently from d931301 to b5ea841 Compare December 18, 2025 13:34
@Yaraslaut

Copy link
Copy Markdown
Member

I guess that you want to close this one: #1541

@christianparpart

Copy link
Copy Markdown
Member Author

I guess that you want to close this one: #1541

i don't know man. macOS is giving me PTSD! We may need to decide to not provide macOS .dmg binary for the imminent release, to not hold it back any longer, and take some more time to get macOS packaging right. (who said Linux packaging is bad, they've never dealt with macOS before :D)

@christianparpart
christianparpart marked this pull request as draft February 18, 2026 16:59
@github-actions github-actions Bot added documentation Improvements or additions to documentation CI GitHub Actions & CI CMake and removed CMake labels Jul 30, 2026
@christianparpart
christianparpart marked this pull request as ready for review August 4, 2026 14:33
…cpkg

The .dmg used to require whatever macOS the build host ran. Homebrew ships one
prebuilt bottle per macOS release and installs the one matching the machine, so
every library Contour linked carried that host's version as its LC_BUILD_VERSION
minimum -- an invisible compatibility cliff that moved whenever the CI image was
rotated.

Build those libraries from source instead, through a vcpkg triplet that pins
VCPKG_OSX_DEPLOYMENT_TARGET, and set CMAKE_OSX_DEPLOYMENT_TARGET before
project(), which is where CMake consumes it. 13.3 is not arbitrary: it is the
first macOS whose libc++ exports the floating-point std::to_chars that <format>
needs, and we use std::format throughout.

Homebrew Qt is no longer installed by default either -- it is split across ~40
per-module prefixes that macdeployqt cannot follow. Release builds use the
official Qt; CONTOUR_INSTALL_BREW_QT=ON restores the old behaviour for dev
builds that are never packaged. The autotools go in because some vcpkg ports on
the way to fontconfig need them on the build host.

The harfbuzz include_directories() hack is now guarded: it is global, and under
vcpkg it would put Homebrew's headers ahead of the libraries we actually link.

Signed-off-by: Christian Parpart <christian@parpart.family>
qt_add_executable() runs Qt's finalization: it imports the QML plugins the app
actually uses and installs Qt's Info.plist template. That last part matters more
than it looks -- CMake only falls back to searching CMAKE_MODULE_PATH for its own
MacOSXBundleInfo.plist.in when MACOSX_BUNDLE_INFO_PLIST is unset, and Qt sets it.
Our copy of that template was therefore never read by anything, so it goes, along
with the two properties Qt's template does not substitute (CFBundleGetInfoString
and CFBundleLongVersionString, both deprecated by Apple regardless).

Qt's template also supplies LSMinimumSystemVersion from
CMAKE_OSX_DEPLOYMENT_TARGET, which is how the declared floor stays tied to the
one the build actually targets, and omits NSHighResolutionCapable, whose absence
is the enabled state.

MACOSX_BUNDLE_ICON_FILE is set at last: the .icns was already installed to
Contents/Resources, but with CFBundleIconFile empty both the app and the disk
image showed the generic executable icon.

Signed-off-by: Christian Parpart <christian@parpart.family>
Gatekeeper rejected every .dmg this project produced, for five independent
reasons: the bundle was missing frameworks, it was signed with --deep, it had no
hardened runtime, it was never notarized, and the disk image itself was neither
signed nor stapled. Any one of those is fatal to distribution.

scripts/macos-bundle.py carries the four steps that fix it. `verify` walks every
Mach-O in the bundle, resolves each @rpath/@loader_path/@executable_path
reference against the referring image's LC_RPATH *and* the main executable's, and
fails on anything unresolvable or pointing outside the bundle -- which is what
turns macdeployqt's silent breakage into a build failure, since it reports
"Cannot resolve rpath" on stderr and still exits 0. `prune` drops the plugin
categories that can never load, from an allowlist, so an unexpected one fails
loudly instead of vanishing. `sign` strips extended attributes and signs
inside-out with --options=runtime and a secure timestamp, entitlements on the
outermost signature only. `notarize` submits, waits, and staples.

verify also reports the highest minimum-OS across the bundled binaries, and
CONTOUR_MACOS_MIN_SUPPORTED turns that report into an assertion: an artifact
users could not launch now fails the build instead of reaching them.

CPack runs after install(), so the image needs its own pass -- the pre-build hook
notarizes and staples the staged app before hdiutil wraps it (the ticket has to be
inside the image for the dragged-out copy to launch offline), and the post-build
hook signs, notarizes and staples the .dmg.

Every execute_process() in the install path gains COMMAND_ERROR_IS_FATAL ANY. A
failed macdeployqt or codesign used to produce a green build and a broken .dmg.

Signed-off-by: Christian Parpart <christian@parpart.family>
There were two ways to produce a .dmg and they disagreed. Whatever CI passed on
the command line was the configuration users downloaded, and it was exercised
nowhere else; the local package preset was a different build that nobody shipped.

`macos-package` is now the only package preset: official Qt, the vcpkg triplet,
notarization and stapling on, CONTOUR_MACOS_MIN_SUPPORTED=13.3, and
CONTOUR_INSTALL_TOOLS=OFF so bench-headless stays out of a user-facing bundle.
appleclang-release keeps its Homebrew Qt for day-to-day work and loses its
package preset, which was the second source of truth.

The Developer ID moves into a hidden `macos-signing` preset that all three
inherit, so the identity is written once.

Signed-off-by: Christian Parpart <christian@parpart.family>
The job configured its own build by hand, so the .dmg users downloaded came from
a configuration nothing else ran. It now uses the macos-package preset and
overrides only what a preset cannot know: which identity this run's keychain
holds, and whether notarization secrets exist.

Signing is gated on the secrets being reachable rather than on the branch name.
GitHub withholds secrets from fork pull requests, which is the real precondition;
the branch test approximated it badly in both directions -- fork PRs asked
codesign for a Developer ID that was in no keychain, breaking every PR run, while
the signing path on a release branch went unexercised until it was already on
master. It has to be proven before the merge.

The keychain import gains -T /usr/bin/codesign and set-key-partition-list,
without which codesign blocks on a GUI prompt that never arrives and the job
hangs until timeout. Notarization uses an App Store Connect API key: no personal
account, scopable to the Developer role, revocable on its own.

The runner is pinned to macos-15 so an image rotation cannot silently change what
ships, and Qt comes from install-qt-action as it already did on Linux and
Windows.

Finally, an assertion that the artifact is something a user can open: mount the
image, drag the app out as a user would, mark the copy quarantined, and require
stapler validate and spctl --type exec to accept it. A ticket stapled only to the
.dmg does not travel with that copy. A green package step used to mean only that
hdiutil had succeeded.

The DMG is also named from VERSION_STRING, matching the .deb -- `version` omits
the -prerelease suffix, so prereleases published a .dmg whose name claimed
otherwise.

Signed-off-by: Christian Parpart <christian@parpart.family>
The existing note recommended `codesign --force --deep`, which is the practice
this branch removed. Replaced with the actual procedure: how to obtain and store
the Developer ID and the App Store Connect key, why release builds use vcpkg and
official Qt rather than Homebrew, the two macdeployqt failure modes worth
knowing, how the minimum-macOS floor is set and enforced, and a troubleshooting
table for the errors that are hard to search for -- including why the
entitlements plist carries no comments (AMFI rejects XML comments outright).

Added to the mkdocs nav, where the old page was never listed.

Signed-off-by: Christian Parpart <christian@parpart.family>
@christianparpart
christianparpart merged commit e4241cd into master Aug 4, 2026
34 checks passed
@christianparpart
christianparpart deleted the fix/macos-signing branch August 4, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI GitHub Actions & CI CMake documentation Improvements or additions to documentation frontend Contour Terminal Emulator (GUI frontend) no changelog Tells the CI to not require a changelog entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants