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: writeto 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@mainThe six actions are:
| Action | What it does |
|---|---|
setup-nucleus | Installs the JBR (or GraalVM Liberica NIK), Linux packaging tools, Gradle, and Node. |
setup-macos-signing | Creates a temporary keychain and imports a .p12 certificate for codesign. |
build-macos-universal | Merges arm64 and x64 .app bundles with lipo, re-signs, and repackages as ZIP, DMG, and PKG. |
build-windows-appxbundle | Combines amd64 and arm64 .appx files into a .msixbundle and signs it. |
generate-update-yml | Computes SHA-512 hashes and emits the latest-*.yml metadata the auto-updater reads. |
publish-release | Creates 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:
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:
| Input | Default | Purpose |
|---|---|---|
jbr-version | 25.0.2b329.66 | JetBrains Runtime version to install. |
jbr-variant | jbrsdk | JBR variant (jbrsdk, jbrsdk_jcef, …). |
jbr-download-url | — | Override the full JBR download URL. |
packaging-tools | true | Install the Linux packaging tools (Linux only). |
flatpak | false | Install Flatpak and the Freedesktop Platform/SDK 24.08 (Linux only). |
snap | false | Install snapd and Snapcraft (Linux only). |
graalvm | false | Use GraalVM (Liberica NIK) instead of the JBR. |
graalvm-java-version | 25 | GraalVM Java version. |
setup-gradle | true | Set up Gradle via gradle/actions/setup-gradle. |
setup-node | true | Set up Node.js. |
node-version | 24 | Node.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:
| Secret | Purpose |
|---|---|
MAC_CERTIFICATES_P12 | Base64-encoded .p12 bundle. |
MAC_CERTIFICATES_PASSWORD | .p12 password. |
MAC_DEVELOPER_ID_APPLICATION | Developer ID Application identity (DMG/ZIP). |
MAC_APP_STORE_APPLICATION | 3rd Party Mac Developer Application identity (sandboxed PKG). |
MAC_APP_STORE_INSTALLER | 3rd Party Mac Developer Installer identity (PKG). |
MAC_PROVISIONING_PROFILE | Base64 provisioning profile for the sandboxed app. |
MAC_RUNTIME_PROVISIONING_PROFILE | Base64 provisioning profile for the JVM runtime. |
MAC_NOTARIZATION_APPLE_ID, MAC_NOTARIZATION_PASSWORD, MAC_NOTARIZATION_TEAM_ID | notarytool 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-bundleGenerate 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 --> publishThat 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-*.ymlmetadata.