CI/CD¶
Nucleus provides a setup-nucleus composite action and ready-to-use GitHub Actions workflows for building, packaging, and publishing desktop applications across all platforms.
Overview¶
A typical release pipeline has four stages:
Tag push (v1.0.0)
│
▼
┌──────────────────────────────────┐
│ Build (6 parallel runners) │
│ Ubuntu amd64 / arm64 │
│ Windows amd64 / arm64 │
│ macOS arm64 / x64 │
│ (macOS: + sandboxed .app ZIP) │
└──────────┬───────────────────────┘
│
┌─────┴──────┐
▼ ▼
┌─────────────┐ ┌──────────┐
│ macOS │ │ Windows │
│ Universal │ │ MSIX │
│ Binary │ │ Bundle │
│ + Signing │ └────┬─────┘
│ + Notarize │ │
└──────┬──────┘ │
│ │
▼ ▼
┌──────────────────────────────────┐
│ Publish — GitHub Release │
│ + Update YML metadata │
└──────────────────────────────────┘
setup-nucleus Action¶
The setup-nucleus composite action (.github/actions/setup-nucleus) sets up the complete build environment: JetBrains Runtime 25, packaging tools, Gradle, and Node.js — all cross-platform.
Usage¶
- uses: ./.github/actions/setup-nucleus
with:
jbr-version: '25b176.4'
packaging-tools: 'true'
flatpak: 'true'
snap: 'true'
setup-gradle: 'true'
setup-node: 'true'
Inputs¶
| Input | Default | Description |
|---|---|---|
jbr-version |
25.0.2b315.62 |
JBR version (e.g. 25b176.4, 25.0.2b315.62) |
jbr-variant |
jbrsdk |
JBR variant (jbrsdk, jbrsdk_jcef, etc.) |
jbr-download-url |
— | Override complete JBR download URL (bypasses version/variant) |
packaging-tools |
true |
Install xvfb, rpm, fakeroot (Linux only) |
flatpak |
false |
Install Flatpak + Freedesktop SDK 24.08 (Linux only) |
snap |
false |
Install Snapd + Snapcraft (Linux only) |
setup-gradle |
true |
Setup Gradle via gradle/actions/setup-gradle@v4 |
setup-node |
false |
Setup Node.js (needed for electron-builder) |
node-version |
20 |
Node.js version when setup-node is true |
Outputs¶
| Output | Description |
|---|---|
java-home |
Path to the JBR installation |
What It Does¶
The action automatically:
- Downloads and installs JBR 25 for the current platform and architecture (Linux x64/aarch64, macOS x64/arm64, Windows x64/arm64)
- Sets JAVA_HOME and adds JBR to PATH
- Installs Linux packaging tools (xvfb, rpm, fakeroot) and starts Xvfb with DISPLAY=:99
- Installs Flatpak + Freedesktop SDK 24.08 (if enabled)
- Installs Snapd + Snapcraft (if enabled)
- Sets up Gradle caching via gradle/actions/setup-gradle@v4
- Sets up Node.js (if enabled)
Release Build¶
Build native packages for all platforms on tag push.
Build Matrix¶
# .github/workflows/release.yaml
name: Release Desktop App (All Platforms)
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build (${{ matrix.os }} / ${{ matrix.arch }})
runs-on: ${{ matrix.os }}
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
# Linux
- os: ubuntu-latest
arch: amd64
- os: ubuntu-24.04-arm
arch: arm64
# Windows
- os: windows-latest
arch: amd64
- os: windows-11-arm
arch: arm64
# macOS
- os: macos-latest
arch: arm64
- os: macos-15-intel
arch: amd64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Normalize version for manual runs
if: github.event_name == 'workflow_dispatch'
shell: bash
run: |
set -euo pipefail
tag="$(git describe --tags --abbrev=0)"
echo "RELEASE_VERSION=$tag" >> "$GITHUB_ENV"
- name: Setup Nucleus
uses: ./.github/actions/setup-nucleus
with:
jbr-version: '25b176.4'
packaging-tools: 'true'
flatpak: 'true'
snap: 'true'
setup-gradle: 'true'
setup-node: 'true'
- name: Build packages
shell: bash
run: ./gradlew packageReleaseDistributionForCurrentOS --stacktrace --no-daemon
- uses: actions/upload-artifact@v4
with:
name: release-assets-${{ runner.os }}-${{ matrix.arch }}
path: |
build/compose/binaries/**/*.dmg
build/compose/binaries/**/*.pkg
build/compose/binaries/**/*.exe
build/compose/binaries/**/*.msi
build/compose/binaries/**/*.appx
build/compose/binaries/**/*.deb
build/compose/binaries/**/*.rpm
build/compose/binaries/**/*.AppImage
build/compose/binaries/**/*.snap
build/compose/binaries/**/*.flatpak
build/compose/binaries/**/*.zip
build/compose/binaries/**/*.tar
build/compose/binaries/**/*.7z
build/compose/binaries/**/*.blockmap
build/compose/binaries/**/signing-metadata.json
build/compose/binaries/**/packaging-metadata.json
!build/compose/binaries/**/app/**
!build/compose/binaries/**/runtime/**
if-no-files-found: error
Custom JBR URL (per-matrix entry)¶
You can override the JBR download URL for specific matrix entries. This is useful for custom JBR builds (e.g. with RTL patches):
matrix:
include:
- os: macos-latest
arch: arm64
jbr-download-url: 'https://example.com/jbr-25-macos-aarch64-custom.tar.gz'
- os: macos-15-intel
arch: amd64
jbr-download-url: 'https://example.com/jbr-25-macos-x64-custom.tar.gz'
steps:
- uses: ./.github/actions/setup-nucleus
with:
jbr-version: '25b176.4'
jbr-download-url: ${{ matrix.jbr-download-url || '' }}
Version from Tag¶
The RELEASE_VERSION environment variable is set from the Git tag. In your build.gradle.kts:
val releaseVersion = System.getenv("RELEASE_VERSION")
?.removePrefix("v")
?.takeIf { it.isNotBlank() }
?: "1.0.0"
nucleus.application {
nativeDistributions {
packageVersion = releaseVersion
}
}
Universal macOS Binaries¶
Merge arm64 and x64 builds into a universal (fat) binary using lipo, then optionally sign and notarize. Nucleus includes reusable composite actions (setup-macos-signing and build-macos-universal):
universal-macos:
name: Universal macOS Binary
needs: [build]
if: needs.build.result == 'success'
runs-on: macos-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
.github/actions
example/packaging/macos
fetch-depth: 1
- uses: actions/setup-node@v4
with:
node-version: '20'
# Setup signing (conditional — skipped if secrets not configured)
- name: Setup macOS signing
id: signing
if: ${{ secrets.MAC_CERTIFICATES_P12 != '' }}
uses: ./.github/actions/setup-macos-signing
with:
certificate-base64: ${{ secrets.MAC_CERTIFICATES_P12 }}
certificate-password: ${{ secrets.MAC_CERTIFICATES_PASSWORD }}
# Decode provisioning profiles for App Store PKG
- name: Decode provisioning profiles
if: ${{ secrets.MAC_PROVISIONING_PROFILE != '' }}
shell: bash
run: |
echo "${{ secrets.MAC_PROVISIONING_PROFILE }}" | base64 -d > "$RUNNER_TEMP/embedded.provisionprofile"
echo "PROVISIONING=$RUNNER_TEMP/embedded.provisionprofile" >> "$GITHUB_ENV"
if [[ -n "${{ secrets.MAC_RUNTIME_PROVISIONING_PROFILE }}" ]]; then
echo "${{ secrets.MAC_RUNTIME_PROVISIONING_PROFILE }}" | base64 -d > "$RUNNER_TEMP/runtime-embedded.provisionprofile"
echo "RUNTIME_PROVISIONING=$RUNNER_TEMP/runtime-embedded.provisionprofile" >> "$GITHUB_ENV"
fi
- uses: actions/download-artifact@v4
with:
name: release-assets-macOS-arm64
path: artifacts/release-assets-macOS-arm64
- uses: actions/download-artifact@v4
with:
name: release-assets-macOS-amd64
path: artifacts/release-assets-macOS-amd64
- name: Build universal binary
uses: ./.github/actions/build-macos-universal
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 }}
entitlements-file: example/packaging/macos/entitlements.plist
runtime-entitlements-file: example/packaging/macos/runtime-entitlements.plist
provisioning-profile: ${{ env.PROVISIONING }}
runtime-provisioning-profile: ${{ env.RUNTIME_PROVISIONING }}
# Notarize DMG and ZIP (conditional)
- name: Notarize DMG
if: ${{ secrets.MAC_NOTARIZATION_APPLE_ID != '' }}
run: |
DMG="$(find artifacts/release-assets-macOS-universal -name '*.dmg' -type f | head -1)"
xcrun notarytool submit "$DMG" \
--apple-id "${{ secrets.MAC_NOTARIZATION_APPLE_ID }}" \
--password "${{ secrets.MAC_NOTARIZATION_PASSWORD }}" \
--team-id "${{ secrets.MAC_NOTARIZATION_TEAM_ID }}" --wait
xcrun stapler staple "$DMG"
- name: Cleanup keychain
if: always() && steps.signing.outputs.keychain-path != ''
run: security delete-keychain "${{ steps.signing.outputs.keychain-path }}" || true
- uses: actions/upload-artifact@v4
with:
name: release-assets-macOS-universal
path: artifacts/release-assets-macOS-universal
if-no-files-found: error
build-macos-universal Inputs¶
| Input | Required | Description |
|---|---|---|
arm64-path |
Yes | Directory with arm64 artifacts |
x64-path |
Yes | Directory with x64 artifacts |
output-path |
No | Output directory (default: universal-output) |
signing-identity |
No | Developer ID Application identity for DMG/ZIP signing |
app-store-identity |
No | 3rd Party Mac Developer Application identity for App Store PKG |
installer-identity |
No | 3rd Party Mac Developer Installer identity for PKG signing |
keychain-path |
No | Path to keychain from setup-macos-signing |
entitlements-file |
No | Path to entitlements.plist |
runtime-entitlements-file |
No | Path to runtime-entitlements.plist |
provisioning-profile |
No | Path to embedded.provisionprofile for sandboxed app |
runtime-provisioning-profile |
No | Path to runtime provisioning profile |
Windows MSIX Bundle¶
Combine amd64 and arm64 .appx files into a single .msixbundle. Nucleus includes a reusable composite action (build-windows-appxbundle):
bundle-windows:
name: Windows APPX Bundle
needs: [build]
if: needs.build.result == 'success'
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
.github/actions
example/packaging
fetch-depth: 1
- uses: actions/download-artifact@v4
with:
name: release-assets-Windows-amd64
path: artifacts/release-assets-Windows-amd64
- uses: actions/download-artifact@v4
with:
name: release-assets-Windows-arm64
path: artifacts/release-assets-Windows-arm64
- name: Build APPX Bundle
uses: ./.github/actions/build-windows-appxbundle
with:
amd64-path: artifacts/release-assets-Windows-amd64
arm64-path: artifacts/release-assets-Windows-arm64
output-path: artifacts/release-assets-Windows-bundle
certificate-password: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
- uses: actions/upload-artifact@v4
with:
name: release-assets-Windows-bundle
path: artifacts/release-assets-Windows-bundle
if-no-files-found: error
Publish to GitHub Releases¶
After all builds complete, create a GitHub Release with all artifacts and update YML files. Nucleus includes composite actions for both (generate-update-yml and publish-release):
publish:
name: Publish Release
needs: [build, universal-macos, bundle-windows]
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 30
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github/actions
fetch-depth: 1
- uses: actions/download-artifact@v4
with:
path: artifacts
pattern: release-assets-*
- name: Determine version and channel
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
echo "TAG=$TAG" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
if [[ "$VERSION" == *"-alpha"* ]]; then
echo "CHANNEL=alpha" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=prerelease" >> "$GITHUB_ENV"
elif [[ "$VERSION" == *"-beta"* ]]; then
echo "CHANNEL=beta" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=prerelease" >> "$GITHUB_ENV"
else
echo "CHANNEL=latest" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=release" >> "$GITHUB_ENV"
fi
- name: Generate update YML files
uses: ./.github/actions/generate-update-yml
with:
artifacts-path: artifacts
version: ${{ env.VERSION }}
channel: ${{ env.CHANNEL }}
- name: Publish release
uses: ./.github/actions/publish-release
with:
artifacts-path: artifacts
tag: ${{ env.TAG }}
release-type: ${{ env.RELEASE_TYPE }}
Required Secrets Summary¶
| Secret | Used By | Description |
|---|---|---|
GITHUB_TOKEN |
Release workflow | Auto-provided by GitHub Actions |
WIN_CSC_LINK |
Build (Windows) | Base64-encoded .pfx certificate |
WIN_CSC_KEY_PASSWORD |
Build (Windows) | Certificate password |
MAC_CERTIFICATES_P12 |
Universal macOS | Base64-encoded .p12 with all signing certs |
MAC_CERTIFICATES_PASSWORD |
Universal macOS | Password for the .p12 file |
MAC_DEVELOPER_ID_APPLICATION |
Universal macOS | Developer ID Application identity (DMG/ZIP) |
MAC_DEVELOPER_ID_INSTALLER |
Universal macOS | Developer ID Installer identity (optional) |
MAC_APP_STORE_APPLICATION |
Universal macOS | 3rd Party Mac Developer Application identity (PKG) |
MAC_APP_STORE_INSTALLER |
Universal macOS | 3rd Party Mac Developer Installer identity (PKG) |
MAC_PROVISIONING_PROFILE |
Universal macOS | Base64-encoded embedded.provisionprofile |
MAC_RUNTIME_PROVISIONING_PROFILE |
Universal macOS | Base64-encoded runtime provisioning profile |
MAC_NOTARIZATION_APPLE_ID |
Universal macOS | Apple ID for notarization |
MAC_NOTARIZATION_PASSWORD |
Universal macOS | App-specific password for notarization |
MAC_NOTARIZATION_TEAM_ID |
Universal macOS | Apple Team ID for notarization |
Composite Actions Reference¶
Nucleus includes reusable composite actions in .github/actions/:
| Action | Description |
|---|---|
setup-nucleus |
Setup JBR 25, packaging tools, Gradle, Node.js |
setup-macos-signing |
Create temporary keychain and import signing certificates |
build-macos-universal |
Merge arm64 + x64 into universal binary via lipo, sign, and package |
build-windows-appxbundle |
Combine amd64 + arm64 .appx into .msixbundle |
generate-update-yml |
Generate latest-*.yml / beta-*.yml / alpha-*.yml metadata |
publish-release |
Create GitHub Release with all artifacts |
Tips¶
- JBR 25 required: Use
setup-nucleusfor all packaging builds — it installs JBR 25 automatically - Concurrency: Use
concurrencyto prevent parallel releases - fail-fast: false: Continue building other platforms if one fails
- Timeout: Set generous timeouts (120min) for Flatpak/Snap builds
- Caching:
setup-nucleusenables Gradle caching automatically viagradle/actions/setup-gradle@v4 - Sparse checkout: Post-build jobs only need the
.github/actionsdirectory - workflow_dispatch: Add it as a trigger to allow re-running a release manually