Nucleus
Packaging & distribution

CI/CD

Build, sign, and publish a Nucleus desktop app across macOS, Windows, and Linux with the composite GitHub Actions shipped in the Nucleus repository.

In this tutorial, you'll set up a GitHub Actions release pipeline for a Nucleus desktop app. You'll build installers on a per-OS, per-architecture matrix, merge the macOS builds into a universal binary, bundle the Windows builds for the Microsoft Store, generate auto-update metadata, and publish everything to a GitHub Release.

Nucleus ships six composite actions in the NucleusFramework/Nucleus repository. You reference them by path from your own workflow — there is nothing to copy into your project.

Before you start

  • Your project is hosted on GitHub and applies the Nucleus Gradle plugin. See Quickstart.
  • The release workflow needs permissions: contents: write to create releases and upload assets.
  • Installers are not cross-compiled. Each OS/architecture must be built on a matching runner.

The composite actions are addressed as NucleusFramework/Nucleus/.github/actions/<name>@<ref>. Use @main while experimenting and pin to a tag such as @v2.0.0 for production runs.

Reference an action

Add a step that points at an action in the Nucleus repository:

- uses: NucleusFramework/Nucleus/.github/actions/setup-nucleus@main

The six actions are:

ActionWhat it does
setup-nucleusInstalls the JBR (or GraalVM Liberica NIK), Linux packaging tools, Gradle, and Node.
setup-macos-signingCreates a temporary keychain and imports a .p12 certificate for codesign.
build-macos-universalMerges arm64 and x64 .app bundles with lipo, re-signs, and repackages as ZIP, DMG, and PKG.
build-windows-appxbundleCombines amd64 and arm64 .appx files into a .msixbundle and signs it.
generate-update-ymlComputes SHA-512 hashes and emits the latest-*.yml metadata the auto-updater reads.
publish-releaseCreates the GitHub Release with gh release create and uploads the installers and YAML.

Build on every platform

Create .github/workflows/release.yaml. Trigger on version tags, run a matrix across the six runners, set up the environment with setup-nucleus, package for the current OS, and upload the result as an artifact:

.github/workflows/release.yaml
name: Release
on:
  push:
    tags: ['v*']

permissions:
  contents: write

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest,    arch: amd64 }
          - { os: ubuntu-24.04-arm, arch: arm64 }
          - { os: windows-latest,   arch: amd64 }
          - { os: windows-11-arm,   arch: arm64 }
          - { os: macos-latest,     arch: arm64 }
          - { os: macos-15-intel,   arch: amd64 }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: NucleusFramework/Nucleus/.github/actions/setup-nucleus@main
        with:
          jbr-version: '25.0.2b329.66'
          packaging-tools: 'true'
          flatpak: 'true'
          snap: 'true'
      - run: ./gradlew packageReleaseDistributionForCurrentOS --stacktrace --no-daemon
      - uses: actions/upload-artifact@v4
        with:
          name: release-assets-${{ runner.os }}-${{ matrix.arch }}
          path: build/compose/binaries/main/**/*

Later jobs download these artifacts by their release-assets-<os>-<arch> names, so keep that naming scheme.

Set up the build environment

setup-nucleus provisions the whole toolchain in one step. On Linux with packaging-tools: 'true' it installs xvfb, rpm, fakeroot, libarchive-tools, libdbus-1-dev, libglib2.0-dev, libx11-dev, libgtk-3-dev, and patchelf, and starts a virtual display. It also sets up Gradle with caching and installs Node for electron-builder.

Inputs:

InputDefaultPurpose
jbr-version25.0.2b329.66JetBrains Runtime version to install.
jbr-variantjbrsdkJBR variant (jbrsdk, jbrsdk_jcef, …).
jbr-download-urlOverride the full JBR download URL.
packaging-toolstrueInstall the Linux packaging tools (Linux only).
flatpakfalseInstall Flatpak and the Freedesktop Platform/SDK 24.08 (Linux only).
snapfalseInstall snapd and Snapcraft (Linux only).
graalvmfalseUse GraalVM (Liberica NIK) instead of the JBR.
graalvm-java-version25GraalVM Java version.
setup-gradletrueSet up Gradle via gradle/actions/setup-gradle.
setup-nodetrueSet up Node.js.
node-version24Node.js version.

With graalvm: 'true', the action installs Liberica NIK instead of the JBR, selects Xcode 26 on macOS, and sets up MSVC on Windows via ilammy/msvc-dev-cmd@v1.

Sign macOS builds

setup-macos-signing decodes a base64 .p12, imports it into a temporary keychain, and unlocks it for codesign. It exposes the keychain path as an output that build-macos-universal consumes:

- uses: NucleusFramework/Nucleus/.github/actions/setup-macos-signing@main
  id: signing
  with:
    certificate-base64: ${{ secrets.MAC_CERTIFICATES_P12 }}
    certificate-password: ${{ secrets.MAC_CERTIFICATES_PASSWORD }}

End-to-end macOS signing and notarization use these repository secrets:

SecretPurpose
MAC_CERTIFICATES_P12Base64-encoded .p12 bundle.
MAC_CERTIFICATES_PASSWORD.p12 password.
MAC_DEVELOPER_ID_APPLICATIONDeveloper ID Application identity (DMG/ZIP).
MAC_APP_STORE_APPLICATION3rd Party Mac Developer Application identity (sandboxed PKG).
MAC_APP_STORE_INSTALLER3rd Party Mac Developer Installer identity (PKG).
MAC_PROVISIONING_PROFILEBase64 provisioning profile for the sandboxed app.
MAC_RUNTIME_PROVISIONING_PROFILEBase64 provisioning profile for the JVM runtime.
MAC_NOTARIZATION_APPLE_ID, MAC_NOTARIZATION_PASSWORD, MAC_NOTARIZATION_TEAM_IDnotarytool credentials.

Without the MAC_* secrets the pipeline falls back to ad-hoc signing — the same result as an unsigned local build. See Code signing for how to produce and store each secret.

Merge the universal macOS binary

build-macos-universal takes the per-architecture .app bundles, merges every Mach-O binary with lipo, re-signs the result inside-out (.dylib and .jnilib files first with runtime entitlements, then executables, then the runtime, then the bundle), and repackages a universal ZIP, DMG, and PKG:

- uses: NucleusFramework/Nucleus/.github/actions/build-macos-universal@main
  with:
    arm64-path: artifacts/release-assets-macOS-arm64
    x64-path: artifacts/release-assets-macOS-amd64
    output-path: artifacts/release-assets-macOS-universal
    signing-identity: ${{ secrets.MAC_DEVELOPER_ID_APPLICATION }}
    app-store-identity: ${{ secrets.MAC_APP_STORE_APPLICATION }}
    installer-identity: ${{ secrets.MAC_APP_STORE_INSTALLER }}
    keychain-path: ${{ steps.signing.outputs.keychain-path }}

Notarization runs as separate workflow steps with xcrun notarytool submit --wait. The DMG is stapled; the ZIP is notarized but not stapled, so re-zipping never invalidates the blockmap the auto-updater relies on.

On private repositories, macOS runners bill at a 10× minute multiplier against your monthly quota, and --wait keeps the runner active while it polls Apple's notarization service — you pay for that idle time at the macOS rate. Public repositories are free. To cut costs on a private repo, run the signing and notarization steps on a self-hosted macOS runner, which GitHub does not bill for minutes.

Bundle Windows for the Microsoft Store

build-windows-appxbundle combines the amd64 and arm64 .appx files into a single .msixbundle with MakeAppx, then signs it with SignTool — one artifact for Store submission:

- uses: NucleusFramework/Nucleus/.github/actions/build-windows-appxbundle@main
  with:
    amd64-path: artifacts/release-assets-Windows-amd64
    arm64-path: artifacts/release-assets-Windows-arm64
    output-path: artifacts/release-assets-Windows-bundle

Generate auto-update metadata

generate-update-yml walks every downloaded installer, computes its SHA-512, and writes one YAML file per platform. The channel determines the filename prefix — latest, beta, or alpha — producing <channel>-mac.yml, <channel>.yml (Windows), and <channel>-linux.yml:

- uses: NucleusFramework/Nucleus/.github/actions/generate-update-yml@main
  with:
    artifacts-path: artifacts
    version: ${{ env.VERSION }}
    channel: ${{ env.CHANNEL }}

Publish the release

publish-release runs gh release create with the tag, uploads the installers and YAML, and applies the release type. Pass prerelease to mark the release as a pre-release:

- uses: NucleusFramework/Nucleus/.github/actions/publish-release@main
  with:
    artifacts-path: artifacts
    tag: ${{ env.TAG }}
    release-type: ${{ env.RELEASE_TYPE }}

Use the reference workflow

The Nucleus repository ships a complete pipeline that wires all six actions together, at .github/workflows/release-desktop.yaml. It builds the matrix, then fans out into a universal-macOS job and a Windows-bundle job before publishing:

flowchart TB
    tag["Tag push v1.0.0"] --> build
    subgraph build ["Build (6 parallel runners)"]
      ubuntu["Ubuntu amd64 / arm64"]
      windows["Windows amd64 / arm64"]
      macos["macOS arm64 / x64"]
    end
    build --> macos_sign["Universal macOS + sign + notarize"]
    build --> msix["Windows MSIX bundle"]
    macos_sign --> publish["GitHub Release + update YML"]
    msix --> publish

That workflow marks a release as a pre-release when the version contains -alpha or -beta, and selects the matching update channel. A separate validate-release-ref step enforces that v2.x.y-alpha, -beta, and -rc tags point to a commit on the nucleus-2.0 branch.

What's next

  • Code signing — produce and store the macOS, Windows, and Linux signing secrets.
  • Publishing — the publish { } DSL that drives GitHub, S3, and generic targets.
  • Auto-update — how the client consumes the generated latest-*.yml metadata.