Building an Audio Plugin with GitHub Actions (macOS + Linux, multi-arch)

The nitty-gritty details of building an audio plugin with GitHub Actions for macOS and Linux.

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

  1. Matrix build across platforms/architectures.
  2. Install platform dependencies.
  3. Configure and build with CMake.
  4. Run tests via CTest.
  5. Optionally run pluginval validation.
  6. On macOS:
    • import signing certs into a temporary keychain
    • codesign plugin bundles
    • produce signed installer packages
    • notarize and staple
  7. 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 sccache to speed up incremental CI builds
  • Linux (arm64)

    • Runs on runs-on: [self-hosted, Linux, ARM64]
    • Usually disables caching/pluginval by default
    • Assumes you control the machine image and dependencies
  • macOS (universal)

    • Runs on runs-on: [self-hosted, macOS]
    • Builds with -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
    • Runs pluginval
    • Codesigns, packages, notarizes, staples

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.
  • BUILD_DIR

    • Example: Builds
    • CMake build directory (cmake -B $BUILD_DIR ...).
  • DISPLAY

    • Example: :0
    • Needed on Linux because pluginval (and some plugin UI/tooling) expects an X11 display.
  • 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_NAME
  • PRODUCT_NAME
  • VERSION
  • MAJOR_VERSION
  • MINOR_VERSION
  • PATCH_LEVEL
  • BUNDLE_ID
  • COMPANY_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_PATH
  • VST3_PATH
  • AU_PATH
  • AUV3_PATH (if relevant)
  • CLAP_PATH
  • ARTIFACT_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:

  1. Signing identities (certificates + their private keys)
  2. Notarization credentials (Apple ID/app-specific password + Team ID)

Certificates (imported into a temporary keychain)

  • DEV_ID_APP_CERT
  • DEV_ID_APP_PASSWORD
  • DEV_ID_INSTALLER_CERT
  • DEV_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 codesign to sign the plugin bundles (.vst3, .component, .clap).
  • Developer ID Installer certificate
    • Used by productbuild to sign the final .pkg installer.

Identity names (used by codesign/productbuild)

  • DEVELOPER_ID_APPLICATION
    • Example value (conceptual): Developer ID Application: Your Company (TEAMID)
  • DEVELOPER_ID_INSTALLER
    • Example value (conceptual): Developer ID Installer: Your Company (TEAMID)

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

A typical install step includes:

  • X11 + windowing headers
  • audio headers
  • OpenGL headers
  • WebKitGTK dev package (JUCE can require this for certain modules)
  • xvfb for a fake display
  • ninja-build if 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-on list.
  • 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:

  • SettingsActionsRunnersNew 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

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:

  • SettingsActionsRunnersNew 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-hosted
  • Linux
  • ARM64

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/zip tooling 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.
  • -s identity
    • 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 using envsubst)
  • run productbuild to combine component pkgs
  • sign the final .pkg with Developer ID Installer

Example shape:

productbuild \
  --distribution distribution.xml \
  --resources ./resources \
  --sign "$DEVELOPER_ID_INSTALLER" \
  --timestamp \
  "<ArtifactName>.pkg"

Why this exists:

  • productbuild produces 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 .pkg to Apple’s notarization service.
  • Apple scans it for malware and policy compliance.
  • --wait blocks 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_INSTALLER values must match imported identities.
  • 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_ARCHITECTURES and ensure Xcode toolchain supports both slices.

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

Try LucidHarmony

Gorgeous, infinite chord progressions for ambient and scoring — powered by the AI techniques discussed in this article.