This post walks through a practical GitHub Actions setup for building an audio plugin across:
- Linux x86_64 (GitHub-hosted runner)
- Linux arm64 (self-hosted runner required)
- macOS universal (arm64 + x86_64, self-hosted runner)
It also covers what you need for Apple code signing + notarization, what each step does, and how to run your own self-hosted runners.
The examples and naming conventions below mirror a real-world workflow, but the ideas apply to most JUCE/CMake-based plugins.
What the workflow does at a high level
- Matrix build across platforms/architectures.
- Install platform dependencies.
- Configure and build with CMake.
- Run tests via CTest.
- Optionally run pluginval validation.
- On macOS:
- import signing certs into a temporary keychain
- codesign plugin bundles
- produce signed installer packages
- notarize and staple
- Upload build artifacts, and optionally publish a GitHub Release on tags.
Build matrix (multi-platform strategy)
A typical matrix might contain entries like:
-
Linux (x86_64)
- Runs on
ubuntu-22.04(GitHub-hosted) - Uses
clang - Installs JUCE Linux dependencies
- Runs
pluginval - Uses
sccacheto speed up incremental CI builds
- Runs on
-
Linux (arm64)
- Runs on
runs-on: [self-hosted, Linux, ARM64] - Usually disables caching/pluginval by default
- Assumes you control the machine image and dependencies
- Runs on
-
macOS (universal)
- Runs on
runs-on: [self-hosted, macOS] - Builds with
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" - Runs
pluginval - Codesigns, packages, notarizes, staples
- Runs on
Required GitHub Actions environment variables
These are typically set at the top of the workflow under env:.
Global CI env (set in the workflow)
-
BUILD_TYPE- Example:
Release - Used by CMake config/build and for artifact directory naming.
- Example:
-
BUILD_DIR- Example:
Builds - CMake build directory (
cmake -B $BUILD_DIR ...).
- Example:
-
DISPLAY- Example:
:0 - Needed on Linux because
pluginval(and some plugin UI/tooling) expects an X11 display.
- Example:
-
HOMEBREW_NO_INSTALL_CLEANUP=1- Prevents Homebrew from doing aggressive cleanup on macOS runners (saves time).
-
SCCACHE_GHA_ENABLED=true,SCCACHE_CACHE_MULTIARCH=1- Enables sccache’s GitHub Actions cache integration and multi-arch behavior.
-
LICENSE_API_KEY=${{ secrets.LICENSE_API_KEY }}- Optional: if your build embeds/uses a Gumroad licensing API key.
CI env generated by CMake (read from a .env file)
A common pattern is to have CMake write a .env file when CI is set, then append it into $GITHUB_ENV. Example keys:
PROJECT_NAMEPRODUCT_NAMEVERSIONMAJOR_VERSIONMINOR_VERSIONPATCH_LEVELBUNDLE_IDCOMPANY_NAME
These are then used to compute paths and artifact names.
Derived env vars set during the workflow
A follow-up step often defines paths like:
ARTIFACTS_PATHVST3_PATHAU_PATHAUV3_PATH(if relevant)CLAP_PATHARTIFACT_NAME(usually includes product + version + platform + arch)
These are conveniences so subsequent steps don’t repeat path logic.
Required GitHub Secrets (complete checklist)
General
LICENSE_API_KEY- Only required if your build/runtime needs it. LucidHarmony uses this to access the API for Gumroad license keys.
macOS signing + notarization
You need two kinds of credentials:
- Signing identities (certificates + their private keys)
- Notarization credentials (Apple ID/app-specific password + Team ID)
Certificates (imported into a temporary keychain)
DEV_ID_APP_CERTDEV_ID_APP_PASSWORDDEV_ID_INSTALLER_CERTDEV_ID_INSTALLER_PASSWORD
These are typically exported Developer ID certificates (often as .p12) plus the password used to decrypt them.
What they represent:
- Developer ID Application certificate
- Used by
codesignto sign the plugin bundles (.vst3,.component,.clap).
- Used by
- Developer ID Installer certificate
- Used by
productbuildto sign the final.pkginstaller.
- Used by
Identity names (used by codesign/productbuild)
DEVELOPER_ID_APPLICATION- Example value (conceptual):
Developer ID Application: Your Company (TEAMID)
- Example value (conceptual):
DEVELOPER_ID_INSTALLER- Example value (conceptual):
Developer ID Installer: Your Company (TEAMID)
- Example value (conceptual):
These strings must match what security find-identity -v -p codesigning would show after import.
Notarization (Apple notarization service)
NOTARIZATION_USERNAME- Apple ID email used for notarization.
NOTARIZATION_PASSWORD- App-specific password (recommended) for that Apple ID.
TEAM_ID- Your Apple Developer Team ID.
Linux: dependencies and headless pluginval
JUCE-related Linux packages
A typical install step includes:
- X11 + windowing headers
- audio headers
- OpenGL headers
- WebKitGTK dev package (JUCE can require this for certain modules)
xvfbfor a fake displayninja-buildif using Ninja generator
Why DISPLAY and Xvfb are used
pluginval loads plugins and may exercise UI-related code paths. On Linux in CI, there’s no real display server, so you start Xvfb:
- Set
DISPLAY=:0 - Start
/usr/bin/Xvfb :0 &
That provides an X11 display for tools that require one.
Linux arm64: why it requires a self-hosted runner
GitHub-hosted Linux runners are x86_64. If you need native Linux arm64 artifacts (common for ARM servers / devices / some Linux music environments), you typically need:
- a real ARM64 machine (or an ARM64 VM)
- a self-hosted GitHub Actions runner installed on it
In the workflow, this is usually expressed as:
runs-on: ["self-hosted", "Linux", "ARM64"]
Practical notes
- Your runner must have labels matching the
runs-onlist. - You control dependencies; the workflow can skip dependency install steps if your runner image already has them.
- You may choose to skip pluginval on arm64 (it may not be readily available, or may require additional work).
macOS: why a self-hosted runner is used
For many plugin teams, macOS CI ends up self-hosted for a few reasons:
- You need Apple code signing identities available.
- You may want a pinned Xcode version or custom toolchain.
- You may want consistent access to signing/keychain tooling.
- And in our case, we definitely can’t afford the high price point of GitHub Action’s macOS CI.
In the workflow, this appears as:
runs-on: ["self-hosted", "macOS"]
The build can produce a universal binary by setting:
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
How to run a self-hosted GitHub Actions runner (macOS)
1) Create a runner in GitHub
In your repo:
Settings→Actions→Runners→New self-hosted runner- Choose macOS
GitHub will show you exact commands to:
- download the runner tarball
- configure it with a one-time registration token
2) Configure and start the runner
On the Mac:
- Run the
./config.sh ...command GitHub provides - Start it with:
./run.sh
3) Run it as a background service (recommended)
GitHub provides helper scripts to install the runner as a service:
sudo ./svc.sh install
sudo ./svc.sh start
This makes the runner survive reboots and user logouts.
4) macOS prerequisites checklist
- Xcode installed (and license accepted)
- Command line tools available (
xcode-select -p) - Homebrew (if your workflow uses
brew install) - Enough disk space (build artifacts can be large)
How to run a self-hosted GitHub Actions runner (Linux arm64)
1) Create a runner in GitHub
Same path:
Settings→Actions→Runners→New self-hosted runner- Choose Linux and your architecture
2) Configure labels to match your workflow
If your workflow uses:
runs-on: ["self-hosted", "Linux", "ARM64"]
then ensure the runner is registered with labels:
self-hostedLinuxARM64
3) Run as a system service
GitHub’s runner also supports service install on Linux:
sudo ./svc.sh install
sudo ./svc.sh start
4) Linux arm64 prerequisites checklist
- CMake + Ninja (if you use Ninja)
- Clang/GCC toolchain
- Dependencies required by your plugin/framework
7z/ziptooling if you archive artifacts
Apple signing & notarization: what the steps do
A macOS distribution that doesn’t show warnings in Gatekeeper typically needs:
- code signing with Developer ID Application
- hardened runtime enabled
- timestamping
- notarization by Apple
- stapling the notarization ticket
Below is what each step is doing conceptually.
Step 1: Import certificates into a temporary keychain
A common approach is:
- create an ephemeral keychain for the job
- import Developer ID certificates (from GitHub Secrets)
- use that keychain for subsequent
codesign/productbuild
This avoids polluting the machine’s default login keychain and makes CI runs more reproducible.
Step 2: codesign the plugin bundles
Example signing command shape:
codesign --force \
--keychain <temporary.keychain> \
-s "$DEVELOPER_ID_APPLICATION" \
-v <PluginBundlePath> \
--deep --strict --options=runtime --timestamp
What the flags mean:
--force- overwrite any existing signature.
-sidentity- selects your Developer ID Application identity.
--deep- recursively signs nested code (helpers, frameworks). This is often necessary for plugin bundles.
--strict- enforces stricter validation rules.
--options=runtime- enables Hardened Runtime, required for notarization.
--timestamp- adds a trusted timestamp so the signature remains valid after cert expiration.
Why it matters:
- Without codesigning, Gatekeeper will warn or block loading.
- Without hardened runtime, notarization will fail.
Step 3: Build component packages with pkgbuild
Example shape:
pkgbuild \
--identifier "<bundleid>.vst3.pkg" \
--version "$VERSION" \
--component "<Plugin.vst3>" \
--install-location "/Library/Audio/Plug-Ins/VST3" \
"<Product>.vst3.pkg"
What it does:
- Creates an installer component package for a specific plugin format.
- Encodes where it should install on the user’s machine.
Teams often generate separate component pkgs for AU/VST3/CLAP, then combine them.
Step 4: Build a signed distribution package with productbuild
A typical approach:
- generate
distribution.xml(often from a template usingenvsubst) - run
productbuildto combine component pkgs - sign the final
.pkgwith Developer ID Installer
Example shape:
productbuild \
--distribution distribution.xml \
--resources ./resources \
--sign "$DEVELOPER_ID_INSTALLER" \
--timestamp \
"<ArtifactName>.pkg"
Why this exists:
productbuildproduces a single user-friendly installer.- Signing with Developer ID Installer helps Gatekeeper trust the installer.
Step 5: Notarize with notarytool
Example shape:
xcrun notarytool submit "<ArtifactName>.pkg" \
--apple-id "$NOTARIZATION_USERNAME" \
--password "$NOTARIZATION_PASSWORD" \
--team-id "$TEAM_ID" \
--wait
What it does:
- Uploads the
.pkgto Apple’s notarization service. - Apple scans it for malware and policy compliance.
--waitblocks until notarization completes (simplifies CI).
Step 6: Staple the notarization ticket
xcrun stapler staple "<ArtifactName>.pkg"
What it does:
- Attaches Apple’s notarization ticket to the installer.
- Enables offline verification on end-user machines.
Artifact handling and releases
A common pattern:
- Linux builds zip up artifacts and upload via
actions/upload-artifact. - macOS builds upload the notarized
.pkg.
On version tags (e.g. v1.2.3), a separate job can:
- download all artifacts
- create a GitHub Release
- attach zips/pkgs
This keeps CI builds and release publication decoupled.
Common pitfalls
-
Runner labels don’t match
runs-on- Your self-hosted runner must include the exact labels referenced.
-
Codesign identity strings don’t match
- The
DEVELOPER_ID_APPLICATION/DEVELOPER_ID_INSTALLERvalues must match imported identities.
- The
-
Notarization auth fails
- Use an app-specific password.
- Ensure the Apple ID has access to the Team ID.
-
pluginval fails on Linux
- Usually due to missing X11/Xvfb or missing JUCE dependencies.
-
Universal builds produce only one architecture
- Verify
CMAKE_OSX_ARCHITECTURESand ensure Xcode toolchain supports both slices.
- Verify
Quick checklist
-
Linux x86_64
- GitHub-hosted runner OK
- install JUCE deps + Xvfb
- run pluginval
-
Linux arm64
- self-hosted runner required
- label runner
Linux+ARM64
-
macOS universal
- self-hosted runner recommended
- import Developer ID certs
- codesign plugin bundles
- pkgbuild + productbuild
- notarytool submit
- stapler staple
-
Secrets configured
- signing certs + passwords
- signing identity strings
- notarization credentials
Closing thoughts
The combination of a matrix build plus self-hosted runners gives you a clean path to shipping plugins across architectures without forcing everything into a single machine type. The key is being explicit about:
- what the runners provide
- what the workflow installs
- what is signed vs notarized vs stapled
Once those contracts are clear (and secrets are correct), the pipeline becomes boring — in the best possible way.
The complete GithubActions workflow
name: Release
on:
workflow_dispatch: # We only build from the UI
# When pushing new commits, cancel any running builds on that branch
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
env:
BUILD_TYPE: Release
BUILD_DIR: Builds
DISPLAY: :0 # linux pluginval needs this
HOMEBREW_NO_INSTALL_CLEANUP: 1
SCCACHE_GHA_ENABLED: true
SCCACHE_CACHE_MULTIARCH: 1
LICENSE_API_KEY: ${{ secrets.LICENSE_API_KEY }}
defaults:
run:
shell: bash
# all steps run in series
jobs:
build_and_test:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # show all errors for each platform (vs. cancel jobs on error)
matrix:
include:
- name: Linux (x86_64)
platform: Linux
os: "ubuntu-22.04"
arch: x86_64
sccache-enabled: true
install-linux-deps: true
setup-clang: true
pluginval-binary: ./pluginval
pluginval-platform: Linux
pluginval-enabled: true
extra-flags: -G Ninja
- name: Linux (arm64)
platform: Linux
os: ["self-hosted", "Linux", "ARM64"]
arch: arm64
sccache-enabled: false
install-linux-deps: false
setup-clang: false
pluginval-enabled: false
extra-flags: -G Ninja
- name: macOS
platform: macOS
os: ["self-hosted", "macOS"]
arch: universal
setup-xcode: false
sccache-enabled: false
pluginval-binary: pluginval.app/Contents/MacOS/pluginval
pluginval-platform: macOS
pluginval-enabled: true
extra-flags: -G Ninja -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
steps:
# Use clang on Linux so we don't introduce a 3rd compiler
- name: Set up Clang
if: ${{ runner.os == 'Linux' && matrix.setup-clang }}
uses: egor-tensin/setup-clang@v1
# This also starts up our "fake" display (Xvfb), needed for pluginval
- name: Install JUCE's Linux Deps
if: ${{ runner.os == 'Linux' && matrix.install-linux-deps }}
# Thanks to McMartin & co https://forum.juce.com/t/list-of-juce-dependencies-under-linux/15121/44
run: |
sudo apt-get update
WEBKIT_PKG="libwebkit2gtk-4.0-dev"
if ! apt-cache show "$WEBKIT_PKG" >/dev/null 2>&1; then
WEBKIT_PKG="libwebkit2gtk-4.1-dev"
fi
sudo apt-get install -y libasound2-dev libx11-dev libxinerama-dev libxext-dev libfreetype6-dev "$WEBKIT_PKG" libglu1-mesa-dev xvfb ninja-build
sudo /usr/bin/Xvfb $DISPLAY &
- name: Install macOS Deps
if: ${{ matrix.platform == 'macOS' }}
run: brew install ninja osxutils
# This block can be removed once 15.1 is default (JUCE requires it when building on macOS 14)
- name: Use latest Xcode on system (macOS)
if: ${{ matrix.platform == 'macOS' && matrix.setup-xcode }}
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Verify Xcode (macOS)
if: ${{ matrix.platform == 'macOS' }}
run: |
xcodebuild -version
xcode-select -p
xcrun --sdk macosx --show-sdk-path
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Cache the build
if: ${{ matrix.sccache-enabled }}
uses: mozilla-actions/sccache-action@v0.0.9
- name: Configure
run: |
SCCACHE_FLAGS=""
if [[ "${{ matrix.sccache-enabled }}" == "true" ]]; then
SCCACHE_FLAGS="-DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache"
fi
cmake -B ${{ env.BUILD_DIR }} -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE}} $SCCACHE_FLAGS ${{ matrix.extra-flags }} .
- name: Build
run: cmake --build ${{ env.BUILD_DIR }} --config ${{ env.BUILD_TYPE }}
- name: Test & Benchmarks
working-directory: ${{ env.BUILD_DIR }}
run: ctest --verbose --output-on-failure
- name: Read in .env from CMake # see GitHubENV.cmake
run: |
cat .env # show us the config
cat .env >> $GITHUB_ENV # pull in our PRODUCT_NAME, etc
- name: Set additional env vars for next steps
run: |
ARTIFACTS_PATH=${{ env.BUILD_DIR }}/${{ env.PROJECT_NAME }}_artefacts/${{ env.BUILD_TYPE }}
echo "ARTIFACTS_PATH=$ARTIFACTS_PATH" >> $GITHUB_ENV
echo "VST3_PATH=$ARTIFACTS_PATH/VST3/${{ env.PRODUCT_NAME }}.vst3" >> $GITHUB_ENV
echo "AU_PATH=$ARTIFACTS_PATH/AU/${{ env.PRODUCT_NAME }}.component" >> $GITHUB_ENV
echo "AUV3_PATH=$ARTIFACTS_PATH/AUv3/${{ env.PRODUCT_NAME }}.appex" >> $GITHUB_ENV
echo "CLAP_PATH=$ARTIFACTS_PATH/CLAP/${{ env.PRODUCT_NAME }}.clap" >> $GITHUB_ENV
echo "ARTIFACT_NAME=${{ env.PRODUCT_NAME }}-${{ env.VERSION }}-${{ matrix.platform }}-${{ matrix.arch }}" >> $GITHUB_ENV
- name: Pluginval
if: ${{ matrix.pluginval-enabled }}
run: |
curl -LO "https://github.com/Tracktion/pluginval/releases/download/v1.0.3/pluginval_${{ matrix.pluginval-platform }}.zip"
if command -v 7z >/dev/null 2>&1; then
7z x pluginval_${{ matrix.pluginval-platform }}.zip
else
unzip -q pluginval_${{ matrix.pluginval-platform }}.zip
fi
${{ matrix.pluginval-binary }} --strictness-level 10 --verbose --validate "${{ env.VST3_PATH }}"
- name: Import Certificates (macOS)
uses: sudara/basic-macos-keychain-action@v1
id: keychain
if: ${{ matrix.platform == 'macOS'}}
with:
dev-id-app-cert: ${{ secrets.DEV_ID_APP_CERT }}
dev-id-app-password: ${{ secrets.DEV_ID_APP_PASSWORD }}
dev-id-installer-cert: ${{ secrets.DEV_ID_INSTALLER_CERT }}
dev-id-installer-password: ${{ secrets.DEV_ID_INSTALLER_PASSWORD }}
- name: Codesign (macOS)
if: ${{ matrix.platform == 'macOS' }}
timeout-minutes: 5
run: |
# Each plugin must be code signed
codesign --force --keychain ${{ steps.keychain.outputs.keychain-path }} -s "${{ secrets.DEVELOPER_ID_APPLICATION}}" -v "${{ env.VST3_PATH }}" --deep --strict --options=runtime --timestamp
codesign --force --keychain ${{ steps.keychain.outputs.keychain-path }} -s "${{ secrets.DEVELOPER_ID_APPLICATION}}" -v "${{ env.AU_PATH }}" --deep --strict --options=runtime --timestamp
codesign --force --keychain ${{ steps.keychain.outputs.keychain-path }} -s "${{ secrets.DEVELOPER_ID_APPLICATION}}" -v "${{ env.CLAP_PATH }}" --deep --strict --options=runtime --timestamp
- name: Add Custom Icons (macOS)
if: ${{ matrix.platform == 'macOS' }}
run: |
# add the icns as its own icon resource (meta!)
sips -i packaging/pamplejuce.icns
# Grab the resource, put in tempfile
DeRez -only icns packaging/pamplejuce.icns > /tmp/icons
# Stuff the resource into the strange Icon? file's resource fork
Rez -a /tmp/icons -o "${{ env.VST3_PATH }}/Icon"$'\r'
Rez -a /tmp/icons -o "${{ env.AU_PATH }}/Icon"$'\r'
Rez -a /tmp/icons -o "${{ env.CLAP_PATH }}/Icon"$'\r'
# Set custom icon attribute
SetFile -a C "${{ env.VST3_PATH }}"
SetFile -a C "${{ env.AU_PATH }}"
SetFile -a C "${{ env.CLAP_PATH }}"
- name: pkgbuild, Productbuild and Notarize
if: ${{ matrix.platform == 'macOS' }}
timeout-minutes: 5
run: |
pkgbuild --identifier "${{ env.BUNDLE_ID }}.au.pkg" --version $VERSION --component "${{ env.AU_PATH }}" --install-location "/Library/Audio/Plug-Ins/Components" "packaging/${{ env.PRODUCT_NAME }}.au.pkg"
pkgbuild --identifier "${{ env.BUNDLE_ID }}.vst3.pkg" --version $VERSION --component "${{ env.VST3_PATH }}" --install-location "/Library/Audio/Plug-Ins/VST3" "packaging/${{ env.PRODUCT_NAME }}.vst3.pkg"
pkgbuild --identifier "${{ env.BUNDLE_ID }}.clap.pkg" --version $VERSION --component "${{ env.CLAP_PATH }}" --install-location "/Library/Audio/Plug-Ins/CLAP" "packaging/${{ env.PRODUCT_NAME }}.clap.pkg"
cd packaging
envsubst < distribution.xml.template > distribution.xml
productbuild --resources ./resources --distribution distribution.xml --sign "${{ secrets.DEVELOPER_ID_INSTALLER }}" --timestamp "${{ env.ARTIFACT_NAME }}.pkg"
xcrun notarytool submit "${{ env.ARTIFACT_NAME }}.pkg" --apple-id ${{ secrets.NOTARIZATION_USERNAME }} --password ${{ secrets.NOTARIZATION_PASSWORD }} --team-id ${{ secrets.TEAM_ID }} --wait
xcrun stapler staple "${{ env.ARTIFACT_NAME }}.pkg"
- name: Zip
if: ${{ matrix.platform == 'Linux' }}
working-directory: ${{ env.ARTIFACTS_PATH }}
run: 7z a -tzip "${{ env.ARTIFACT_NAME }}.zip" "-xr!lib${{ env.PRODUCT_NAME }}_SharedCode.a" .
- name: Upload Zip (Linux)
if: ${{ matrix.platform == 'Linux' }}
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}.zip
path: "${{ env.ARTIFACTS_PATH }}/${{ env.ARTIFACT_NAME }}.zip"
retention-days: 7
- name: Upload pkg (macOS)
if: ${{ matrix.platform == 'macOS' }}
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}.pkg
path: packaging/${{ env.ARTIFACT_NAME }}.pkg
retention-days: 7
release:
if: contains(github.ref, 'tags/v')
runs-on: ubuntu-latest
needs: build_and_test
steps:
- name: Get Artifacts
uses: actions/download-artifact@v4
- name: Create Release
uses: softprops/action-gh-release@v2
with:
prerelease: true
# download-artifact puts these files in their own dirs...
# Using globs sidesteps having to pass the version around
files: |
*/*.zip
*/*.dmg
*/*.pkg