name: Release on: push: tags: - "v*" workflow_dispatch: inputs: version: description: "Version to build (e.g. 1.1.4). Overrides tag when set." required: true type: string skip_publish: description: "Skip GitHub Release upload" required: false type: choice options: - "false" - "true" default: "false" force_update_release: description: "Overwrite existing GitHub Release for this version instead of failing" required: false type: choice options: - "false" - "true" default: "false" concurrency: group: release-${{ github.ref }} cancel-in-progress: true env: GRADLE_OPTS: >- -Dorg.gradle.jvmargs=-Xmx6g -Dkotlin.daemon.jvm.options=-Xmx6g -Dorg.gradle.parallel=true jobs: resolve-version: name: Resolve version runs-on: ubuntu-latest permissions: contents: read outputs: version: ${{ steps.ver.outputs.version }} tag: ${{ steps.ver.outputs.tag }} prerelease: ${{ steps.ver.outputs.prerelease }} release_label: ${{ steps.ver.outputs.release_label }} force_update_release: ${{ steps.ver.outputs.force_update_release }} steps: - id: ver shell: bash env: GH_TOKEN: ${{ github.token }} run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then V="${{ inputs.version }}" else V="${GITHUB_REF_NAME#v}" fi if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$ ]]; then echo "Invalid version: $V" >&2 exit 1 fi if [[ ! "$V" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then echo "Cannot derive release label from version: $V" >&2 exit 1 fi MAJOR="${BASH_REMATCH[1]}" MINOR="${BASH_REMATCH[2]}" PATCH="${BASH_REMATCH[3]}" if [[ "$PATCH" == "0" ]]; then RELEASE_LABEL="${MAJOR}.${MINOR}" else RELEASE_LABEL="${MAJOR}.${MINOR}.${PATCH}" fi TAG="v$V" echo "version=$V" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "release_label=$RELEASE_LABEL" >> "$GITHUB_OUTPUT" case "$V" in *-beta*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;; *) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;; esac if gh release view "$TAG" >/dev/null 2>&1; then if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_update_release }}" = "true" ]; then echo "Release $TAG exists; force_update_release enabled — publish will overwrite it." echo "force_update_release=true" >> "$GITHUB_OUTPUT" else echo "Release $TAG already exists. Delete it first, or run workflow_dispatch with force_update_release=true." >&2 exit 1 fi else echo "force_update_release=false" >> "$GITHUB_OUTPUT" fi desktop-proguard: name: Build JAR needs: resolve-version runs-on: ubuntu-latest timeout-minutes: 45 env: FROMCHAT_PREBUILT_PROGUARD: "0" steps: - uses: actions/checkout@v5 - uses: ./.github/actions/restore-git-mtimes - uses: ./.github/actions/set-version with: version: ${{ needs.resolve-version.outputs.version }} - uses: ./.github/actions/setup-java-gradle - uses: ./.github/actions/cache-gradle-build id: gradle-build-cache with: scope: build-jar - name: Compile and ProGuard release desktop jars run: ./gradlew :app:desktop:exportReleaseProguardForCi --no-daemon --console=plain - uses: ./.github/actions/save-gradle-build-cache if: always() with: scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }} - uses: actions/upload-artifact@v5 with: name: desktop-proguard path: app/desktop/build/ci/proguard/ if-no-files-found: error retention-days: 3 windows-common: name: Windows installer needs: resolve-version runs-on: windows-latest timeout-minutes: 30 steps: - uses: actions/checkout@v5 - uses: ./.github/actions/restore-git-mtimes - uses: ./.github/actions/set-version with: version: ${{ needs.resolve-version.outputs.version }} - uses: ./.github/actions/setup-java-gradle - uses: ./.github/actions/cache-gradle-build id: gradle-build-cache with: scope: windows-installer - uses: dtolnay/rust-toolchain@stable with: targets: x86_64-pc-windows-msvc - uses: ./.github/actions/cache-windows-rust-tools id: rust-tools-cache - name: Verify Windows Rust binaries id: rust-binaries shell: bash run: | ready=true for exe in \ FromChat-Installer.exe \ FromChat-Installer-Helper.exe \ fromchat-portable-launcher.exe \ fromchat-pack.exe \ fromchat-icon-patch.exe do if [[ ! -f "app/desktop/windows-setup/target/release/$exe" ]]; then echo "Missing $exe" ready=false fi done echo "ready=$ready" >> "$GITHUB_OUTPUT" - name: Build Windows installer Rust tooling if: steps.rust-binaries.outputs.ready != 'true' shell: bash run: ./gradlew :app:desktop:buildWindowsSetupRust --no-daemon --console=plain - uses: ./.github/actions/save-windows-rust-cache if: always() - uses: actions/upload-artifact@v5 with: name: windows-rust-tools path: app/desktop/windows-setup/target/release/*.exe if-no-files-found: error retention-days: 3 - uses: ./.github/actions/save-gradle-build-cache if: always() with: scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }} windows-package: name: Windows (${{ matrix.label }}) needs: [resolve-version, desktop-proguard, windows-common] env: FROMCHAT_PREBUILT_PROGUARD: "1" strategy: fail-fast: false matrix: include: - arch: x64 label: x64 runner: windows-latest - arch: arm64 label: ARM runner: windows-11-arm runs-on: ${{ matrix.runner }} timeout-minutes: 60 steps: - uses: actions/checkout@v5 - uses: ./.github/actions/restore-git-mtimes - uses: ./.github/actions/set-version with: version: ${{ needs.resolve-version.outputs.version }} - name: Ensure ARM64 packaging JDK if: matrix.arch == 'arm64' shell: cmd run: call scripts\ensure-windows-arm64-jdk.cmd - uses: ./.github/actions/setup-java-gradle if: matrix.arch == 'x64' with: cache-read-only: "true" - uses: gradle/actions/setup-gradle@v5 if: matrix.arch == 'arm64' with: gradle-version: wrapper cache-read-only: true add-job-summary: on-failure - uses: ./.github/actions/cache-gradle-build id: gradle-build-cache with: scope: windows-package-${{ matrix.arch }} - uses: actions/download-artifact@v5 with: name: desktop-proguard path: app/desktop/build/prebuilt/main-release/proguard - name: Restore Windows Rust installer tools uses: actions/download-artifact@v5 with: name: windows-rust-tools path: app/desktop/windows-setup/target/release - name: Package Windows ${{ matrix.arch }} installer shell: bash env: FROMCHAT_SKIP_RUST_BUILD: "1" run: | export FROMCHAT_PACKAGING_JDK="$JAVA_HOME" args=( :app:desktop:packSetupOnly "-PdesktopArch=${{ matrix.arch }}" --no-daemon --console=plain ) if [ "${{ matrix.arch }}" = "arm64" ]; then args+=("-PwindowsArm64") fi ./gradlew "${args[@]}" - uses: actions/upload-artifact@v5 with: name: windows-${{ matrix.arch }} path: app/desktop/build/distributions/release/*-windows-${{ matrix.arch }}.exe if-no-files-found: error - uses: ./.github/actions/save-gradle-build-cache if: always() with: scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }} macos: name: macOS (${{ matrix.label }}) needs: [resolve-version, desktop-proguard] env: FROMCHAT_PREBUILT_PROGUARD: "1" strategy: fail-fast: false matrix: include: - arch: arm64 label: Apple Silicon - arch: x64 label: Intel runs-on: macos-latest timeout-minutes: 60 steps: - uses: actions/checkout@v5 - uses: ./.github/actions/restore-git-mtimes - uses: ./.github/actions/set-version with: version: ${{ needs.resolve-version.outputs.version }} - uses: ./.github/actions/setup-java-gradle if: matrix.arch == 'x64' with: java-architecture: x64 cache-read-only: "true" - uses: ./.github/actions/setup-java-gradle if: matrix.arch == 'arm64' with: cache-read-only: "true" - uses: ./.github/actions/cache-gradle-build id: gradle-build-cache with: scope: macos-${{ matrix.arch }} - uses: actions/download-artifact@v5 with: name: desktop-proguard path: app/desktop/build/prebuilt/main-release/proguard - name: Cache DMG background tooling uses: actions/cache@v5 with: path: | app/desktop/dmg-background/node_modules ~/.cache/ms-playwright key: dmg-tools-${{ runner.arch }}-${{ hashFiles('app/desktop/dmg-background/package-lock.json') }} restore-keys: dmg-tools-${{ runner.arch }}- - name: Install macOS packaging tools run: | if ! command -v create-dmg >/dev/null 2>&1; then brew install create-dmg fi DMG_DIR="app/desktop/dmg-background" if [[ ! -f "$DMG_DIR/node_modules/playwright/package.json" ]]; then npm --prefix "$DMG_DIR" ci --no-fund --no-audit fi npm --prefix "$DMG_DIR" exec playwright install chromium - name: Package macOS ${{ matrix.arch }} run: | export FROMCHAT_PACKAGING_JDK="$JAVA_HOME" ./gradlew :app:desktop:packageReleaseMac -PdesktopArch=${{ matrix.arch }} --no-daemon --console=plain - uses: actions/upload-artifact@v5 with: name: macos-${{ matrix.arch }} path: app/desktop/build/distributions/release/*-macOS-${{ matrix.arch }}.dmg if-no-files-found: error - uses: ./.github/actions/save-gradle-build-cache if: always() with: scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }} linux: name: Linux (${{ matrix.label }}) needs: [resolve-version, desktop-proguard] env: FROMCHAT_PREBUILT_PROGUARD: "1" strategy: fail-fast: false matrix: include: - arch: x64 label: x64 runner: ubuntu-latest appimage: appimagetool-x86_64.AppImage - arch: arm64 label: ARM runner: ubuntu-24.04-arm appimage: appimagetool-aarch64.AppImage runs-on: ${{ matrix.runner }} timeout-minutes: 45 steps: - uses: actions/checkout@v5 - uses: ./.github/actions/restore-git-mtimes - uses: ./.github/actions/set-version with: version: ${{ needs.resolve-version.outputs.version }} - uses: ./.github/actions/setup-java-gradle with: cache-read-only: "true" - uses: ./.github/actions/cache-gradle-build id: gradle-build-cache with: scope: linux-${{ matrix.arch }} - uses: actions/download-artifact@v5 with: name: desktop-proguard path: app/desktop/build/prebuilt/main-release/proguard - name: Install Linux packaging tools run: | sudo apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ fakeroot rpm binutils libfuse2 wget CACHE_DIR="${RUNNER_TOOL_CACHE}/appimagetool" mkdir -p "$CACHE_DIR" TOOL="$CACHE_DIR/appimagetool-${{ matrix.arch }}.AppImage" if [[ ! -f "$TOOL" ]]; then wget -q "https://github.com/AppImage/appimagetool/releases/download/continuous/${{ matrix.appimage }}" -O "$TOOL" chmod +x "$TOOL" fi echo "APPIMAGETOOL=$TOOL" >> "$GITHUB_ENV" - name: Package Linux ${{ matrix.arch }} run: | export FROMCHAT_PACKAGING_JDK="$JAVA_HOME" ./gradlew :app:desktop:packageReleaseLinux -PdesktopArch=${{ matrix.arch }} --no-daemon --console=plain - uses: actions/upload-artifact@v5 with: name: linux-${{ matrix.arch }} path: | app/desktop/build/distributions/release/*-linux-${{ matrix.arch }}.deb app/desktop/build/distributions/release/*-linux-${{ matrix.arch }}.rpm app/desktop/build/distributions/release/*-linux-${{ matrix.arch }}.AppImage if-no-files-found: error - uses: ./.github/actions/save-gradle-build-cache if: always() with: scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }} android: name: Android needs: resolve-version runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@v5 - uses: ./.github/actions/restore-git-mtimes - uses: ./.github/actions/set-version with: version: ${{ needs.resolve-version.outputs.version }} - uses: ./.github/actions/setup-java-gradle with: cache-read-only: "true" - uses: android-actions/setup-android@v4 - uses: ./.github/actions/cache-gradle-build id: gradle-build-cache with: scope: android - name: Cache Android SDK extras uses: actions/cache@v5 with: path: | ~/.android/build-cache ~/.android/cache key: android-sdk-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'app/android/build.gradle.kts') }} restore-keys: android-sdk-${{ runner.os }}- - name: Prepare Android signing env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }} RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }} RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} run: | if [[ -z "$KEYSTORE_BASE64" || -z "$GOOGLE_SERVICES_JSON" ]]; then echo "Missing ANDROID_KEYSTORE_BASE64 or ANDROID_GOOGLE_SERVICES_JSON secrets" >&2 exit 1 fi mkdir -p app/android/keys printf '%s' "$KEYSTORE_BASE64" | base64 -d > app/android/keys/release.jks keytool -genkeypair -noprompt \ -alias key0 -keyalg RSA -keysize 2048 -validity 10000 \ -dname "CN=FromChat Debug" \ -keystore app/android/keys/debug.jks \ -storepass android -keypass android { echo "releaseStorePassword=${RELEASE_STORE_PASSWORD}" echo "releaseKeyPassword=${RELEASE_KEY_PASSWORD}" echo "debugStorePassword=android" echo "debugKeyPassword=android" } > app/android/keys/keystore.properties printf '%s' "$GOOGLE_SERVICES_JSON" | base64 -d > app/android/google-services.json - name: Build universal Android APK run: ./gradlew :app:android:assembleRelease --no-daemon --console=plain - uses: ./.github/actions/save-gradle-build-cache if: always() with: scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }} - name: Stage APK run: | mkdir -p release-staging APK="$(find app/android/build/outputs/apk/release -name '*release*.apk' | head -n 1)" cp "$APK" "release-staging/FromChat-${{ needs.resolve-version.outputs.release_label }}.apk" - uses: actions/upload-artifact@v5 with: name: android path: release-staging/*.apk if-no-files-found: error publish: if: ${{ github.event_name != 'workflow_dispatch' || inputs.skip_publish != 'true' }} needs: - resolve-version - windows-package - macos - linux - android runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v5 with: sparse-checkout: | RELEASE_NOTES.md sparse-checkout-cone-mode: false - uses: actions/download-artifact@v5 with: pattern: "{windows,macos,linux}-*" merge-multiple: true path: artifacts - uses: actions/download-artifact@v5 with: name: android path: artifacts - name: Collect release assets env: LABEL: ${{ needs.resolve-version.outputs.release_label }} run: | set -euo pipefail if [[ ! -d artifacts ]]; then echo "No artifacts were downloaded" >&2 exit 1 fi mkdir -p release-assets pick_one() { local pattern="$1" local dest="$2" local match match=$(find artifacts -type f -name "$pattern" | head -n 1) if [[ -z "$match" ]]; then echo "Missing artifact matching $pattern" >&2 exit 1 fi cp "$match" "release-assets/$dest" } pick_one "*-windows-x64.exe" "FromChat-${LABEL}-x64.exe" pick_one "*-windows-arm64.exe" "FromChat-${LABEL}-arm.exe" pick_one "*-macOS-arm64.dmg" "FromChat-${LABEL}-apple.dmg" pick_one "*-macOS-x64.dmg" "FromChat-${LABEL}-intel.dmg" pick_one "*-linux-x64.deb" "FromChat-${LABEL}-x64.deb" pick_one "*-linux-arm64.deb" "FromChat-${LABEL}-arm.deb" pick_one "*-linux-x64.rpm" "FromChat-${LABEL}-x64.rpm" pick_one "*-linux-arm64.rpm" "FromChat-${LABEL}-arm.rpm" pick_one "*-linux-x64.AppImage" "FromChat-${LABEL}-x64.AppImage" pick_one "*-linux-arm64.AppImage" "FromChat-${LABEL}-arm.AppImage" pick_one "*.apk" "FromChat-${LABEL}.apk" if find release-assets -type f -name '*.zip' | grep -q .; then echo "Release assets must not be zip archives" >&2 exit 1 fi ls -la release-assets - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.resolve-version.outputs.tag }} name: ${{ needs.resolve-version.outputs.tag }} body_path: RELEASE_NOTES.md draft: false prerelease: ${{ needs.resolve-version.outputs.prerelease == 'true' }} generate_release_notes: false overwrite_existing: ${{ needs.resolve-version.outputs.force_update_release == 'true' }} files: | release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}.apk release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.exe release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.exe release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-apple.dmg release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-intel.dmg release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.deb release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.deb release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.rpm release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.rpm release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.AppImage release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.AppImage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}