Symptom

Krill Desktop built .deb (Linux) and .msi (Windows) artifacts and the Compose nativeDistributions block even listed TargetFormat.Dmg, but no pipeline ever produced a macOS installer — so Mac users had nothing to download.

Root cause

Two gaps, neither in application code: (1) jpackage can only emit a .dmg from a macOS host, and there was no CI job running on the existing self-hosted macOS runner; (2) the desktop packageVersion was a hand-edited literal, decoupled from the canonical version.txt. macOS desktop is the same jvm("desktop") target as Linux/Windows, so no new Kotlin target or expect/actual was needed — only a macOS build host, packaging metadata, and a publish workflow.

Fix

composeApp/build.gradle.kts: read packageVersion from the repo-root version.txt, and add a macOS { } block (bundle ID zone.krill.desktop, app name, optional signing gated on the APPLE_SIGNING_IDENTITY env var so v1 ships unsigned). New .github/workflows/Deploy macOS.yml runs on [self-hosted, macOS, ARM64], builds :composeApp:packageReleaseDmg, and publishes versioned + latest keys to s3://cms.krill.systems/distro/macos/ with a CloudFront invalidation (E1MKRDHOFUF59Y, the same distribution the screenshot/SDK-docs workflows use). A Download-category post documents the link and the Gatekeeper open-steps for the unsigned build.

Prevention

When a cross-platform desktop target advertises an installer format (TargetFormat.Dmg), confirm a build host for that OS actually exists in CI — the format being listed does not mean anything ships. Drive every installer’s version from version.txt, never a hand-edited literal, so all platforms stay in lockstep at release time. Platform-specific runtime surfaces in desktopMain (the expect/actuals) are only proven on the OSes CI actually runs the app on; a Linux-only build pipeline silently leaves the macOS paths unexercised — track that as a manual smoke checklist until a macOS build exists.

Local build verification (Mac Mini ARM64, JDK 21)

Signing + notarization wired in (resolves task 5b.1)

The .dmg now ships signed by Developer ID Application: Sautner Studio LLC (HDUTVLGRF7) and notarized + stapled by Apple, so Gatekeeper opens it without the “unidentified developer” warning. spctl -a -t open --context context:primary-signature -vv <dmg> reports accepted / source=Notarized Developer ID.

Local setup on the macOS runner (one-time)

Build flow

Compose’s macOS { signing { } } block signs the .app with the hardened runtime (flags=0x10000(runtime) in codesign -dvvv) when the APPLE_SIGNING_IDENTITY env var is set; the env var is the full identity name ("Developer ID Application: …"), not a SHA-1. The .dmg shell itself is then signed, submitted, and stapled by .github/workflows/Deploy macOS.yml — see the Sign + notarize + staple .dmg step, gated on secrets.APPLE_SIGNING_IDENTITY. Absent secret → the workflow falls through to the unsigned publish path, preserving v1 behavior.

.app staple is intentionally one-shot

The notarization ticket is stapled to the .dmg shell, not to the .app inside it (the .dmg is a read-only image; jpackage would re-sign and clobber any prior .app staple if we tried). For a downloaded .dmg, Gatekeeper validates the stapled ticket on the container without internet; on first launch of the installed .app Gatekeeper does an online check against Apple’s notary, which succeeds because the .app’s CDHash is recorded with the original .dmg submission. The realistic offline-first case (user copied the .dmg over via thumb drive and is offline on first launch) would prompt — a v2 improvement is to notarize the .app standalone before packaging and staple it too; for v1 the .dmg-only staple is sufficient because users get the .dmg from CloudFront, which implies online.