Merge pull request #474 from OpenHub-Store/upload-workflow #40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Desktop Platform Installers | |
| on: | |
| push: | |
| branches: | |
| - generate-installers | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| JAVA_VERSION: '21' | |
| JAVA_DISTRIBUTION: 'temurin' | |
| GRADLE_OPTS: >- | |
| -Dorg.gradle.daemon=false | |
| -Dorg.gradle.parallel=true | |
| -Dorg.gradle.caching=true | |
| -Dorg.gradle.vfs.watch=false | |
| jobs: | |
| build-windows: | |
| runs-on: windows-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Validate Gradle wrapper | |
| uses: gradle/actions/wrapper-validation@v4 | |
| - name: Set up JDK ${{ env.JAVA_VERSION }} | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: ${{ env.JAVA_DISTRIBUTION }} | |
| java-version: ${{ env.JAVA_VERSION }} | |
| - name: Setup Gradle | |
| uses: gradle/actions/setup-gradle@v4 | |
| with: | |
| cache-read-only: false | |
| cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} | |
| gradle-home-cache-cleanup: true | |
| - name: Grant execute permission for gradlew | |
| run: chmod +x gradlew | |
| shell: bash | |
| - name: Build Windows installers (EXE & MSI) | |
| run: | | |
| set -euo pipefail | |
| retry() { | |
| local n=1 max=3 delay=5 | |
| while true; do | |
| echo "Attempt #$n: $*" | |
| "$@" && break | |
| [ $n -ge $max ] && { echo "Failed after $n attempts."; return 1; } | |
| n=$((n+1)); echo "Retrying in ${delay}s..."; sleep $delay; delay=$((delay*2)) | |
| done | |
| } | |
| retry ./gradlew :composeApp:packageExe :composeApp:packageMsi | |
| shell: bash | |
| - name: Upload Windows installers | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: windows-installers | |
| path: | | |
| composeApp/build/compose/binaries/main/exe/*.exe | |
| composeApp/build/compose/binaries/main/msi/*.msi | |
| retention-days: 30 | |
| compression-level: 6 | |
| build-macos: | |
| strategy: | |
| matrix: | |
| include: | |
| - os: macos-15-intel | |
| arch: x64 | |
| - os: macos-latest | |
| arch: arm64 | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Validate Gradle wrapper | |
| uses: gradle/actions/wrapper-validation@v4 | |
| - name: Set up JDK ${{ env.JAVA_VERSION }} | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: ${{ env.JAVA_DISTRIBUTION }} | |
| java-version: ${{ env.JAVA_VERSION }} | |
| - name: Setup Gradle | |
| uses: gradle/actions/setup-gradle@v4 | |
| with: | |
| cache-read-only: false | |
| cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} | |
| gradle-home-cache-cleanup: true | |
| - name: Grant execute permission for gradlew | |
| run: chmod +x gradlew | |
| - name: Build macOS installers (DMG & PKG) | |
| run: | | |
| set -euo pipefail | |
| retry() { | |
| local n=1 max=3 delay=5 | |
| while true; do | |
| echo "Attempt #$n: $*" | |
| "$@" && break | |
| [ $n -ge $max ] && { echo "Failed after $n attempts."; return 1; } | |
| n=$((n+1)); echo "Retrying in ${delay}s..."; sleep $delay; delay=$((delay*2)) | |
| done | |
| } | |
| retry ./gradlew :composeApp:packageDmg :composeApp:packagePkg | |
| shell: bash | |
| - name: Upload macOS installers | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: macos-installers-${{ matrix.arch }} | |
| path: | | |
| composeApp/build/compose/binaries/main/dmg/*.dmg | |
| composeApp/build/compose/binaries/main/pkg/*.pkg | |
| retention-days: 30 | |
| compression-level: 6 | |
| build-linux: | |
| strategy: | |
| matrix: | |
| include: | |
| - os: ubuntu-latest | |
| label: modern | |
| gradle-tasks: >- | |
| :composeApp:packageDeb | |
| :composeApp:packageRpm | |
| :composeApp:packageAppImage | |
| - os: ubuntu-22.04 | |
| label: debian12-compat | |
| gradle-tasks: >- | |
| :composeApp:packageDeb | |
| :composeApp:packageRpm | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Validate Gradle wrapper | |
| uses: gradle/actions/wrapper-validation@v4 | |
| - name: Set up JDK ${{ env.JAVA_VERSION }} | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: ${{ env.JAVA_DISTRIBUTION }} | |
| java-version: ${{ env.JAVA_VERSION }} | |
| - name: Setup Gradle | |
| uses: gradle/actions/setup-gradle@v4 | |
| with: | |
| cache-read-only: false | |
| cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} | |
| gradle-home-cache-cleanup: true | |
| - name: Grant execute permission for gradlew | |
| run: chmod +x gradlew | |
| - name: Build Linux installers | |
| run: | | |
| set -euo pipefail | |
| retry() { | |
| local n=1 max=3 delay=5 | |
| while true; do | |
| echo "Attempt #$n: $*" | |
| "$@" && break | |
| [ $n -ge $max ] && { echo "Failed after $n attempts."; return 1; } | |
| n=$((n+1)); echo "Retrying in ${delay}s..."; sleep $delay; delay=$((delay*2)) | |
| done | |
| } | |
| retry ./gradlew ${{ matrix.gradle-tasks }} | |
| shell: bash | |
| - name: List AppImage build output | |
| if: matrix.label == 'modern' | |
| run: | | |
| echo "=== Listing build output ===" | |
| find composeApp/build/compose/binaries/main -maxdepth 3 -type d 2>/dev/null || echo "Directory not found" | |
| echo "=== All files ===" | |
| find composeApp/build/compose/binaries/main -maxdepth 4 -type f 2>/dev/null | head -30 || echo "No files found" | |
| shell: bash | |
| - name: Build AppImage with appimagetool | |
| if: matrix.label == 'modern' | |
| run: | | |
| set -euo pipefail | |
| # Find the directory containing the app launcher (bin/GitHub-Store) | |
| APP_ROOT="" | |
| for candidate in \ | |
| composeApp/build/compose/binaries/main/app-image/GitHub-Store \ | |
| composeApp/build/compose/binaries/main/app/GitHub-Store \ | |
| composeApp/build/compose/binaries/main/app-image \ | |
| composeApp/build/compose/binaries/main/app; do | |
| if [ -f "$candidate/bin/GitHub-Store" ]; then | |
| APP_ROOT="$candidate" | |
| echo "Found app root at: $candidate" | |
| break | |
| fi | |
| done | |
| if [ -z "$APP_ROOT" ]; then | |
| echo "ERROR: Could not find app launcher (bin/GitHub-Store)" | |
| find composeApp/build/compose/binaries/main -type f -name "GitHub-Store" 2>/dev/null || true | |
| exit 1 | |
| fi | |
| # Download appimagetool | |
| wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage | |
| chmod +x appimagetool-x86_64.AppImage | |
| # Create AppDir from Compose output | |
| APPDIR="GitHub-Store.AppDir" | |
| mv "$APP_ROOT" "$APPDIR" | |
| # Create AppRun entry point | |
| cat > "$APPDIR/AppRun" << 'EOF' | |
| #!/bin/bash | |
| SELF=$(readlink -f "$0") | |
| HERE=${SELF%/*} | |
| exec "${HERE}/bin/GitHub-Store" "$@" | |
| EOF | |
| chmod +x "$APPDIR/AppRun" | |
| # Create .desktop file | |
| cat > "$APPDIR/github-store.desktop" << 'EOF' | |
| [Desktop Entry] | |
| Type=Application | |
| Name=GitHub Store | |
| Exec=GitHub-Store | |
| Icon=github-store | |
| Categories=Development; | |
| Comment=Cross-platform app store for GitHub releases | |
| EOF | |
| # Copy icon to AppDir root (required by appimagetool) | |
| cp "$APPDIR/lib/GitHub-Store.png" "$APPDIR/github-store.png" | |
| # Build .AppImage | |
| OUTPUT="composeApp/build/compose/binaries/main/GitHub-Store-x86_64.AppImage" | |
| UPINFO="gh-releases-zsync|rainxchzed|Github-Store|latest|*x86_64.AppImage.zsync" | |
| ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool-x86_64.AppImage -u "$UPINFO" "$APPDIR" "$OUTPUT" | |
| # appimagetool may place .zsync in the working directory; move it next to the AppImage | |
| ZSYNC_NAME="$(basename "$OUTPUT").zsync" | |
| if [ -f "$ZSYNC_NAME" ] && [ ! -f "$OUTPUT.zsync" ]; then | |
| mv "$ZSYNC_NAME" "$OUTPUT.zsync" | |
| fi | |
| echo "Created AppImage and zsync:" | |
| ls -lh "$OUTPUT" "$OUTPUT.zsync" | |
| shell: bash | |
| - name: Patch deb scripts for headless/WSL compatibility | |
| run: | | |
| set -euo pipefail | |
| for deb in composeApp/build/compose/binaries/main/deb/*.deb; do | |
| [ -f "$deb" ] || continue | |
| echo "Patching: $deb" | |
| tmpdir=$(mktemp -d) | |
| dpkg-deb -R "$deb" "$tmpdir" | |
| for script in "$tmpdir/DEBIAN/postinst" "$tmpdir/DEBIAN/prerm" "$tmpdir/DEBIAN/postrm"; do | |
| [ -f "$script" ] || continue | |
| # Make xdg-desktop-menu / xdg-icon-resource / xdg-mime calls non-fatal | |
| # so install/remove succeeds in headless environments (WSL, containers, servers) | |
| sed -i '/xdg-desktop-menu\|xdg-icon-resource\|xdg-mime/{/|| true$/!s/$/ || true/}' "$script" | |
| done | |
| dpkg-deb -b "$tmpdir" "$deb" | |
| rm -rf "$tmpdir" | |
| echo "Patched successfully: $deb" | |
| done | |
| shell: bash | |
| - name: Upload Linux installers | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: linux-installers-${{ matrix.label }} | |
| path: | | |
| composeApp/build/compose/binaries/main/deb/*.deb | |
| composeApp/build/compose/binaries/main/rpm/*.rpm | |
| retention-days: 30 | |
| compression-level: 6 | |
| - name: Upload Linux AppImage | |
| if: matrix.label == 'modern' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: linux-appimage | |
| path: | | |
| composeApp/build/compose/binaries/main/GitHub-Store-x86_64.AppImage | |
| composeApp/build/compose/binaries/main/GitHub-Store-x86_64.AppImage.zsync | |
| if-no-files-found: error | |
| retention-days: 30 | |
| compression-level: 0 | |
| - name: Build Arch Linux package (.pkg.tar.zst) | |
| if: matrix.label == 'modern' | |
| run: | | |
| set -euo pipefail | |
| VERSION=$(grep 'projectVersionName' gradle/libs.versions.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/') | |
| PKG_NAME="github-store" | |
| PKG_DIR="pkg-root" | |
| # Find the app directory produced by packageAppImage | |
| APP_ROOT="" | |
| for candidate in \ | |
| composeApp/build/compose/binaries/main/app/GitHub-Store \ | |
| composeApp/build/compose/binaries/main/app-image/GitHub-Store; do | |
| if [ -f "$candidate/bin/GitHub-Store" ]; then | |
| APP_ROOT="$candidate" | |
| break | |
| fi | |
| done | |
| # Fall back to the AppDir we created earlier if still present | |
| if [ -z "$APP_ROOT" ] && [ -f "GitHub-Store.AppDir/bin/GitHub-Store" ]; then | |
| APP_ROOT="GitHub-Store.AppDir" | |
| fi | |
| if [ -z "$APP_ROOT" ]; then | |
| echo "ERROR: Could not find app directory for Arch packaging" | |
| exit 1 | |
| fi | |
| # Build the package tree | |
| mkdir -p "$PKG_DIR/opt/github-store" | |
| cp -a "$APP_ROOT"/. "$PKG_DIR/opt/github-store/" | |
| # Launcher symlink | |
| mkdir -p "$PKG_DIR/usr/bin" | |
| ln -s "/opt/github-store/bin/GitHub-Store" "$PKG_DIR/usr/bin/github-store" | |
| # Desktop entry | |
| mkdir -p "$PKG_DIR/usr/share/applications" | |
| cat > "$PKG_DIR/usr/share/applications/github-store.desktop" << 'EOF' | |
| [Desktop Entry] | |
| Type=Application | |
| Name=GitHub Store | |
| Exec=/opt/github-store/bin/GitHub-Store | |
| Icon=github-store | |
| Categories=Development; | |
| Comment=Cross-platform app store for GitHub releases | |
| StartupWMClass=github-store | |
| EOF | |
| sed -i 's/^ //' "$PKG_DIR/usr/share/applications/github-store.desktop" | |
| # Icon | |
| if [ -f "$PKG_DIR/opt/github-store/lib/GitHub-Store.png" ]; then | |
| mkdir -p "$PKG_DIR/usr/share/icons/hicolor/256x256/apps" | |
| cp "$PKG_DIR/opt/github-store/lib/GitHub-Store.png" \ | |
| "$PKG_DIR/usr/share/icons/hicolor/256x256/apps/github-store.png" | |
| fi | |
| # .PKGINFO (pacman metadata β must be a flat file at archive root) | |
| INSTALLED_SIZE=$(du -sb "$PKG_DIR" | cut -f1) | |
| cat > "$PKG_DIR/.PKGINFO" << EOF | |
| pkgname = ${PKG_NAME} | |
| pkgver = ${VERSION}-1 | |
| pkgdesc = Cross-platform app store for GitHub releases | |
| url = https://github.com/OpenHub-Store/GitHub-Store | |
| builddate = $(date +%s) | |
| packager = GitHub Actions | |
| arch = x86_64 | |
| license = GPL-3.0 | |
| size = ${INSTALLED_SIZE} | |
| depend = java-runtime>=21 | |
| depend = hicolor-icon-theme | |
| EOF | |
| sed -i 's/^ //' "$PKG_DIR/.PKGINFO" | |
| # Create .pkg.tar.zst | |
| sudo apt-get install -y zstd | |
| OUTPUT_DIR="composeApp/build/compose/binaries/main/arch" | |
| mkdir -p "$OUTPUT_DIR" | |
| ARCHIVE="${PKG_NAME}-${VERSION}-1-x86_64.pkg.tar.zst" | |
| cd "$PKG_DIR" | |
| # .PKGINFO must come first in the archive | |
| tar --zstd -cf "../${OUTPUT_DIR}/${ARCHIVE}" .PKGINFO opt usr | |
| cd .. | |
| echo "Created Arch package:" | |
| ls -lh "${OUTPUT_DIR}/${ARCHIVE}" | |
| shell: bash | |
| - name: Upload Arch Linux package | |
| if: matrix.label == 'modern' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: linux-arch | |
| path: composeApp/build/compose/binaries/main/arch/*.pkg.tar.zst | |
| if-no-files-found: error | |
| retention-days: 30 | |
| compression-level: 0 | |
| release: | |
| name: Draft release with all installers | |
| needs: [build-windows, build-macos, build-linux] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Read project version | |
| id: version | |
| run: | | |
| set -euo pipefail | |
| VERSION=$(grep 'projectVersionName' gradle/libs.versions.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/') | |
| if [ -z "$VERSION" ]; then | |
| echo "ERROR: could not read projectVersionName from gradle/libs.versions.toml" | |
| exit 1 | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "Resolved release tag: v$VERSION" | |
| shell: bash | |
| - name: Download all build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| - name: Stage release files (rename arch / compat collisions) | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-files | |
| # stage() returns 0 on a successful copy, 1 if the source is missing. | |
| # Callers use the exit status to increment per-group counters so the | |
| # completeness guard at the end can detect missing groups. | |
| stage() { | |
| local src="$1" | |
| local target_name="$2" | |
| if [ -f "$src" ]; then | |
| cp "$src" "release-files/$target_name" | |
| echo "Staged: $target_name" | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| windows_count=0 | |
| macos_x64_count=0 | |
| macos_arm64_count=0 | |
| linux_modern_count=0 | |
| linux_debian12_count=0 | |
| linux_appimage_count=0 | |
| linux_arch_count=0 | |
| # Windows β names already unique (.exe, .msi) | |
| for f in artifacts/windows-installers/*.exe artifacts/windows-installers/*.msi; do | |
| [ -f "$f" ] || continue | |
| stage "$f" "$(basename "$f")" && windows_count=$((windows_count + 1)) || true | |
| done | |
| # macOS β disambiguate x64 vs arm64 (Compose outputs identical filenames per arch) | |
| for f in artifacts/macos-installers-x64/*.dmg artifacts/macos-installers-x64/*.pkg; do | |
| [ -f "$f" ] || continue | |
| base="$(basename "$f")" | |
| ext="${base##*.}" | |
| stem="${base%.*}" | |
| stage "$f" "${stem}-x64.${ext}" && macos_x64_count=$((macos_x64_count + 1)) || true | |
| done | |
| for f in artifacts/macos-installers-arm64/*.dmg artifacts/macos-installers-arm64/*.pkg; do | |
| [ -f "$f" ] || continue | |
| base="$(basename "$f")" | |
| ext="${base##*.}" | |
| stem="${base%.*}" | |
| stage "$f" "${stem}-arm64.${ext}" && macos_arm64_count=$((macos_arm64_count + 1)) || true | |
| done | |
| # Linux modern β default Debian/RPM (unprefixed) | |
| for f in artifacts/linux-installers-modern/*.deb artifacts/linux-installers-modern/*.rpm; do | |
| [ -f "$f" ] || continue | |
| stage "$f" "$(basename "$f")" && linux_modern_count=$((linux_modern_count + 1)) || true | |
| done | |
| # Linux debian12-compat β suffix to avoid collision with modern | |
| for f in artifacts/linux-installers-debian12-compat/*.deb artifacts/linux-installers-debian12-compat/*.rpm; do | |
| [ -f "$f" ] || continue | |
| base="$(basename "$f")" | |
| ext="${base##*.}" | |
| stem="${base%.*}" | |
| stage "$f" "${stem}-debian12.${ext}" && linux_debian12_count=$((linux_debian12_count + 1)) || true | |
| done | |
| # Linux AppImage + zsync (filenames already include -x86_64) | |
| for f in artifacts/linux-appimage/*.AppImage artifacts/linux-appimage/*.AppImage.zsync; do | |
| [ -f "$f" ] || continue | |
| stage "$f" "$(basename "$f")" && linux_appimage_count=$((linux_appimage_count + 1)) || true | |
| done | |
| # Linux Arch (.pkg.tar.zst already has version + arch in filename) | |
| for f in artifacts/linux-arch/*.pkg.tar.zst; do | |
| [ -f "$f" ] || continue | |
| stage "$f" "$(basename "$f")" && linux_arch_count=$((linux_arch_count + 1)) || true | |
| done | |
| echo | |
| echo "Final staged files:" | |
| ls -la release-files/ | |
| echo | |
| echo "Per-group counts: windows=$windows_count macos-x64=$macos_x64_count macos-arm64=$macos_arm64_count linux-modern=$linux_modern_count linux-debian12=$linux_debian12_count linux-appimage=$linux_appimage_count linux-arch=$linux_arch_count" | |
| # Completeness guard: refuse to ship an incomplete release. Each | |
| # group must produce >= 1 staged file. Without this guard, a build | |
| # regression (e.g. AppImage step silently producing nothing) would | |
| # ship a draft release missing the affected group and we'd discover | |
| # it only when users complained. | |
| missing=() | |
| [ "$windows_count" -eq 0 ] && missing+=("Windows installers (.exe/.msi)") | |
| [ "$macos_x64_count" -eq 0 ] && missing+=("macOS x64 (.dmg/.pkg)") | |
| [ "$macos_arm64_count" -eq 0 ] && missing+=("macOS arm64 (.dmg/.pkg)") | |
| [ "$linux_modern_count" -eq 0 ] && missing+=("Linux modern (.deb/.rpm)") | |
| [ "$linux_debian12_count" -eq 0 ] && missing+=("Linux debian12-compat (.deb/.rpm)") | |
| [ "$linux_appimage_count" -eq 0 ] && missing+=("Linux AppImage (.AppImage/.zsync)") | |
| [ "$linux_arch_count" -eq 0 ] && missing+=("Linux Arch (.pkg.tar.zst)") | |
| if [ ${#missing[@]} -gt 0 ]; then | |
| echo | |
| echo "ERROR: missing artifacts for the following groups:" | |
| printf " - %s\n" "${missing[@]}" | |
| echo | |
| echo "Refusing to publish an incomplete release." | |
| exit 1 | |
| fi | |
| shell: bash | |
| - name: Create or update draft release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ steps.version.outputs.tag }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| # Release with this tag exists. Only clobber assets if it's still | |
| # a DRAFT β we never silently overwrite assets on a published | |
| # release. Operator must bump projectVersionName in | |
| # gradle/libs.versions.toml to roll a new tag. | |
| is_draft=$(gh release view "$TAG" --json isDraft -q '.isDraft') | |
| if [ "$is_draft" = "true" ]; then | |
| echo "Draft release $TAG already exists β replacing assets via --clobber..." | |
| gh release upload "$TAG" release-files/* --clobber | |
| else | |
| echo "ERROR: release $TAG is already PUBLISHED." | |
| echo "Refusing to clobber published assets." | |
| echo | |
| echo "If you intended to ship a new version, bump" | |
| echo "projectVersionName in gradle/libs.versions.toml first," | |
| echo "then re-push to generate-installers." | |
| exit 1 | |
| fi | |
| else | |
| echo "Creating new draft release $TAG..." | |
| gh release create "$TAG" \ | |
| --draft \ | |
| --title "$VERSION" \ | |
| --generate-notes \ | |
| release-files/* | |
| fi | |
| shell: bash |