Files
app/.github/workflows/release.yml
denis0001-dev 926343e90e Fix Gradle and Rust cache save failures on parallel release jobs.
Use scope-specific Gradle cache paths, global release concurrency, Rust v3 key with v1/v2 fallbacks, and ensure Android SDK cache dirs exist before save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 16:23:40 +03:00

559 lines
20 KiB
YAML

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
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: Prepare Android SDK cache dirs
run: mkdir -p ~/.android/build-cache ~/.android/cache
- 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 }}