At Localazy, we've always strived to support all major platforms with our CLI — whether it's JVM, NPM, Linux, Windows, macOS, or Docker. Achieving this level of cross-platform compatibility without maintaining multiple codebases was a challenge, but we found an elegant solution in Kotlin Multiplatform (KMP).
Previously, our macOS binaries were signed manually using a chain of commands within GitHub Actions. While functional, there were several problems:
- This method required a preconfigured macOS computer for building, which wasn't scalable when we added ARM64 support.
- This preconfigured computer was an aging Mac Mini (2012) that finally reached the end of its lifespan after 12 years of service.
- And on top of that, macOS 14+ introduced stricter notarization requirements, rendering our existing solution obsolete.
Here's how we solved this problem and how to use GitHub Actions to go through the signing method now.
🎯 One codebase, multiple targets 🔗
Kotlin Multiplatform allowed us to build all our CLI targets from a single codebase, reducing duplication and ensuring consistency across different environments. We still needed some platform-specific code; for example, we developed a custom networking implementation for Windows instead of using the standard Ktor client, which introduced unnecessary dependencies.
In addition to Kotlin MPP, we use several Gradle build scripts, which compile binaries and package them for NPM, Docker, Linux (DEB/RPM), and Homebrew (macOS), simplifying our release pipeline.
Basically, any push to the main
branch builds all the binaries as release candidates and makes them available for our internal testing. A new release or tag automatically builds and distributes binaries and packages to all the mentioned locations.
➡️ Signing & notarizing automatically with Github Actions 🔗
Recently, we upgraded to Kotlin 2.1.10, which enabled us to add native support for ARM64-based macOS, once again taking advantage of our single codebase. However, this transition brought a major challenge: code signing and notarization.
The solution: code-sign-action 🔗
To automate and streamline our signing process, we switched to lando/code-sign-action. After several iterations, we arrived at a simple and effective configuration:
- name: Codesign and Notarization
uses: lando/code-sign-action@v3
with:
file: dist/macosArm64/localazy
certificate-data: ${{ secrets.CLI_MACOS_CERTIFICATE }}
certificate-id: ${{ secrets.CLI_MACOS_TEAM_ID }}
certificate-password: ${{ secrets.CLI_MACOS_CERTIFICATE_PWD }}
apple-notary-user: ${{ secrets.CLI_MACOS_NOTARY_USER }}
apple-notary-password: ${{ secrets.CLI_MACOS_NOTARY_PWD }}
apple-product-id: com.localazy.cli
options: --options runtime --entitlements dist/entitlements.xml
This new approach allowed us to:
- 🏗️ Offload the entire building process to GitHub Action runners (no need for a dedicated macOS machine).
- 🔍 Notarize the binaries for macOS 14+ seamlessly.
- 🔄 Maintain a fully automated release pipeline while still securely complying with Apple's requirements.
How to get the certificate 🔗
Let's see the process in action. First, you need to create a signing request. Since we usually prepare them on a non-macOS machine, we do so using the command line:
openssl genrsa -out localazy.key 2048
openssl req -new -key localazy.key -out localazy.csr -subj "/[email protected], CN=Localazy, C=CZ"
The examples on Apple's site use a key with 3072 bits, but such a key is not yet supported for creating the certificate (as of March 2025), so we need to stick with 2048 bits.
With the signing request ready, sign in to developer.apple.com and navigate to the Certificates section. In the top-right corner, you will find the team ID next to your name (or your company's name). Store the team ID in Github Actions secrets as CLI_MACOS_TEAM_ID.
Next, create a new certificate and select Software > Developer ID Application as the type. This is the only certificate that allows notarization of the resulting binary.
Upload the CSR file and download the final certificate. Now combine the key and certificate to generate a PKCS12 file and then convert it to base64. We accomplish this using the command line as follows:
// Convert CER to PEM
openssl x509 -inform der -in developerID_application.cer -out developerID_application.pem
// Create PKCS12
openssl pkcs12 -export -out localazy.p12 -inkey localazy.key -in developerID_application.pem
During the second step, you will be prompted to enter a password for PKCS12 storage. We store this password in GitHub Actions secrets as CLI_MACOS_CERTIFICATE_PWD.
The final step in this process is executing the last command, which ensures the PKCS12 certificate is correctly encoded for secure storage and further usage. The last command we need is:
base64 localazy.p12 > localazy.base64
We securely store the content of localazy.base64 in GitHub Actions secrets as CLI_MACOS_CERTIFICATE.
Login for the notarization service 🔗
The value of CLI_MACOS_NOTARY_USER in our GitHub Actions secrets is the Apple ID, typically the email address associated with the Apple Developer account.
To obtain the CLI_MACOS_NOTARY_PWD, navigate to Sign-In > Security > App-Specific Passwords at account.apple.com. Here, you can generate a new password specifically for notarization.
entitlements.xml 🔗
We utilize a simple entitlements file to define the necessary permissions for our application.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
Homebrew with multiple targets 🔗
When the new binaries are ready, we package them as tar.gz and upload them to our storage, making them available for distribution. Immediately afterward, we run a simple script to update our Homebrew recipe at https://github.com/localazy/homebrew-tools.
The Homebrew update script performs the following operations:
- Clones the repository localazy/homebrew-tools
- Downloads the tar.gz files for both macOS x86 and macOS ARM64 versions
- Generates a new recipe with updated URLs and SHA checksums
- Automatically commits the changes to our repository
The recipe is as follows:
class Localazy < Formula
desc "CLI tool for the Localazy platform"
homepage "https://localazy.com"
if Hardware::CPU.arm?
url "https://dist.localazy.com/macosArm64/macos-$1.tar.gz"
sha256 "$(shasum -a 256 macos-Arm64-$1.tar.gz | grep -o "^\S*")"
elsif Hardware::CPU.intel?
url "https://dist.localazy.com/macos/macos-$1.tar.gz"
sha256 "$(shasum -a 256 macos-X64-$1.tar.gz | grep -o "^\S*")"
end
depends_on "curl"
def install
bin.install "localazy"
end
test do
system "#{bin}/localazy", "-h"
end
end
As shown above, a simple decision block determines the appropriate URL and SHA256 checksum based on the target platform architecture.
Our CLI is now available on any Mac, whether ARM64 or X64-based, with just two simple commands:
> brew tap localazy/tools
> brew install localazy
✔️ Conclusion 🔗
Using Kotlin Multiplatform, a Gradle automation, and GitHub Actions, we've successfully built a scalable, efficient, and fully automated process for developing, packaging, signing, and distributing the Localazy CLI across multiple platforms.
Plus, by extending this automation to code signing and notarization for Apple binaries, we've added support for ARM64 and significantly improved the security and reliability of our CLI distribution. This allows us to comply with Apple's latest requirements while maintaining a streamlined and efficient workflow.
If you're looking to develop cross-platform CLI tools with robust automation, Kotlin Multiplatform and modern CI/CD practices provide a powerful foundation. We hope these tips were helpful!