Skip to content

Merge pull request #474 from OpenHub-Store/upload-workflow #40

Merge pull request #474 from OpenHub-Store/upload-workflow

Merge pull request #474 from OpenHub-Store/upload-workflow #40

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