Skip to content

Merge remote-tracking branch 'origin/main' into generate-installers #55

Merge remote-tracking branch 'origin/main' into generate-installers

Merge remote-tracking branch 'origin/main' into generate-installers #55

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
outputs:
windows-artifact-id: ${{ steps.upload-windows.outputs.artifact-id }}
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 :composeApp:createDistributable
shell: bash
# Flatten .exe + .msi into a single directory so the uploaded artifact
# zip contains them at the root (no `exe/` and `msi/` subdirectories).
# SignPath's artifact-configuration XML uses simple `*.exe` / `*.msi`
# globs at zip root; nested paths cause "Expected path to match exactly
# 1 item, but found 0" because upload-artifact@v4 strips the longest
# common prefix only, so multi-source paths preserve their tail dirs.
- name: Stage Windows installers for upload
run: |
set -euo pipefail
mkdir -p windows-staging
cp composeApp/build/compose/binaries/main/exe/*.exe windows-staging/
cp composeApp/build/compose/binaries/main/msi/*.msi windows-staging/
ls -la windows-staging/
shell: bash
- name: Upload Windows installers
id: upload-windows
uses: actions/upload-artifact@v4
with:
name: windows-installers
path: windows-staging/*
if-no-files-found: error
retention-days: 30
compression-level: 6
# Portable build: zip the jpackage app-image (launcher + bundled JRE) so
# users can unzip and run GitHub-Store.exe with no installer (#700). The
# launcher is unsigned β€” it ships as its own artifact and never passes
# through the SignPath job, which signs only the .exe/.msi installers.
- name: Stage Windows portable (zip app-image)
shell: pwsh
run: |
$line = (Select-String -Path 'gradle/libs.versions.toml' -Pattern 'projectVersionName' | Select-Object -First 1).Line
$version = [regex]::Match($line, '"(.*?)"').Groups[1].Value
if ([string]::IsNullOrWhiteSpace($version)) { Write-Error "Could not read projectVersionName"; exit 1 }
$appDir = "composeApp/build/compose/binaries/main/app/Komi-Store"
if (-not (Test-Path $appDir)) { $appDir = "composeApp/build/compose/binaries/main/app-image/Komi-Store" }
if (-not (Test-Path $appDir)) { Write-Error "app-image directory not found"; exit 1 }
New-Item -ItemType Directory -Force -Path windows-portable | Out-Null
$zip = "windows-portable/Komi-Store-$version-windows-portable.zip"
Compress-Archive -Path $appDir -DestinationPath $zip -Force
Get-Item $zip | Format-List
- name: Upload Windows portable
uses: actions/upload-artifact@v4
with:
name: windows-portable
path: windows-portable/*
if-no-files-found: error
retention-days: 30
compression-level: 0
sign-windows:
name: Sign Windows installers (SignPath)
needs: build-windows
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
actions: write
steps:
# Fail loudly if any SignPath config is missing instead of letting the
# action surface a generic auth/lookup error halfway through the run.
# Policy slug lives in a repo variable so swapping test-signing β†’
# release-signing once SignPath issues the prod cert is a one-click
# change in repo Settings, with no code edit needed.
- name: Verify SignPath configuration
env:
ORG_ID: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
POLICY_SLUG: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
run: |
set -euo pipefail
missing=()
[ -n "${ORG_ID:-}" ] || missing+=("SIGNPATH_ORGANIZATION_ID (secret)")
[ -n "${API_TOKEN:-}" ] || missing+=("SIGNPATH_API_TOKEN (secret)")
[ -n "${POLICY_SLUG:-}" ] || missing+=("SIGNPATH_SIGNING_POLICY_SLUG (variable)")
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Missing SignPath configuration:"
printf '::error:: - %s\n' "${missing[@]}"
exit 1
fi
echo "Signing policy: ${POLICY_SLUG}"
shell: bash
# Pinned to the v2 release commit instead of @v2 so a force-push to the
# mutable tag (or a compromise of the SignPath org) cannot inject code
# into a job that holds id-token: write and the SignPath API token.
# When bumping, look up the new SHA via:
# gh api repos/signpath/github-action-submit-signing-request/git/refs/tags/<tag>
- name: Submit signing request
uses: signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2 (2025-10-23)
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: 'GitHub-Store'
signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: 'initial'
github-artifact-id: ${{ needs.build-windows.outputs.windows-artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-artifacts
- name: Upload signed Windows installers
uses: actions/upload-artifact@v4
with:
name: windows-installers-signed
path: signed-artifacts/*
if-no-files-found: error
retention-days: 30
compression-level: 0
# Remove the unsigned upload so only the SignPath-signed installers are
# downloadable from the run page and end up in the draft release.
# Without this, both windows-installers (unsigned) and
# windows-installers-signed coexist as 30-day artifacts.
- name: Delete unsigned Windows artifact
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_ID: ${{ needs.build-windows.outputs.windows-artifact-id }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh api -X DELETE "repos/${REPO}/actions/artifacts/${ARTIFACT_ID}"
echo "Deleted unsigned artifact id=${ARTIFACT_ID}"
shell: bash
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
if-no-files-found: error
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/Komi-Store)
APP_ROOT=""
for candidate in \
composeApp/build/compose/binaries/main/app-image/Komi-Store \
composeApp/build/compose/binaries/main/app/Komi-Store \
composeApp/build/compose/binaries/main/app-image \
composeApp/build/compose/binaries/main/app; do
if [ -f "$candidate/bin/Komi-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/Komi-Store)"
find composeApp/build/compose/binaries/main -type f -name "Komi-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="Komi-Store.AppDir"
mv "$APP_ROOT" "$APPDIR"
# Install the JVM-direct launcher that bypasses the jpackage native
# launcher (crashes in glibc setenv() on glibc >= 2.42, see GH#563).
cp packaging/linux/github-store-launcher.sh "$APPDIR/bin/github-store-launcher.sh"
chmod +x "$APPDIR/bin/github-store-launcher.sh"
# Compose strips native commands from the bundled runtime (no bin/java).
# The AppRun -> launcher wrapper execs "lib/runtime/bin/java", so restore
# that launcher from the build JDK; the runtime bundles libjli.so / libjvm.so,
# resolved via $ORIGIN/../lib (GH#746). This AppDir is also the source for the
# Arch package below, so both pick up the fix.
if [ ! -x "$APPDIR/lib/runtime/bin/java" ]; then
mkdir -p "$APPDIR/lib/runtime/bin"
cp "$JAVA_HOME/bin/java" "$APPDIR/lib/runtime/bin/java"
chmod +x "$APPDIR/lib/runtime/bin/java"
fi
# Create AppRun entry point
cat > "$APPDIR/AppRun" << 'EOF'
#!/bin/bash
SELF=$(readlink -f "$0")
HERE=${SELF%/*}
exec "${HERE}/bin/github-store-launcher.sh" "$@"
EOF
chmod +x "$APPDIR/AppRun"
# Create .desktop file
cat > "$APPDIR/github-store.desktop" << 'EOF'
[Desktop Entry]
Type=Application
Name=Komi Store
Exec=Komi-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/Komi-Store.png" "$APPDIR/github-store.png"
# Build .AppImage
OUTPUT="composeApp/build/compose/binaries/main/Komi-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 (headless compat + t64 deps GH#124 + native-launcher bypass GH#563)
run: |
set -euo pipefail
WRAPPER="$(pwd)/packaging/linux/github-store-launcher.sh"
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
# GH#124: glibc time64 ("t64") library rename. jpackage/dpkg-shlibdeps
# emits host-derived deps: t64 names on Ubuntu 24.04+/Debian 13, bare
# names on Ubuntu 22.04/Debian 12. Neither single name installs on both.
# Rewrite to alternative-dependency form so one .deb covers every distro.
control="$tmpdir/DEBIAN/control"
if [ -f "$control" ]; then
if grep -q 'libasound2t64' "$control"; then
sed -i 's/\blibasound2t64\b/libasound2 | libasound2t64/g' "$control"
elif grep -q 'libasound2' "$control"; then
sed -i 's/\blibasound2\b/libasound2 | libasound2t64/g' "$control"
fi
if grep -q 'libpng16-16t64' "$control"; then
sed -i 's/\blibpng16-16t64\b/libpng16-16 | libpng16-16t64/g' "$control"
elif grep -q 'libpng16-16' "$control"; then
sed -i 's/\blibpng16-16\b/libpng16-16 | libpng16-16t64/g' "$control"
fi
echo "Depends after t64 rewrite:"; grep -i '^Depends' "$control" || true
fi
# Replace the jpackage native launcher with the JVM-direct wrapper.
# The native launcher crashes in glibc setenv() on glibc >= 2.42
# before the JVM maps (GH#563). The installed .desktop Exec path is
# unchanged, so it transparently runs the wrapper instead.
launcher="$(find "$tmpdir/opt" -type f -path '*/bin/Komi-Store' 2>/dev/null | head -n1 || true)"
if [ -n "$launcher" ] && head -c4 "$launcher" | grep -q $'\x7fELF'; then
echo "Swapping native launcher: $launcher"
mv "$launcher" "${launcher}.bin"
cp "$WRAPPER" "$launcher"
chmod +x "$launcher"
else
echo "WARNING: native launcher not found or already a script in $deb"
fi
# Compose strips native commands from the bundled runtime, so it ships no
# bin/java. The wrapper above execs "$ROOTDIR/lib/runtime/bin/java"; restore
# that launcher from the build JDK (libjli.so / libjvm.so are bundled and
# resolve via $ORIGIN/../lib, GH#746).
runtime="$(find "$tmpdir/opt" -maxdepth 4 -type d -name runtime | head -n1 || true)"
if [ -n "$runtime" ] && [ ! -x "$runtime/bin/java" ]; then
mkdir -p "$runtime/bin"
cp "$JAVA_HOME/bin/java" "$runtime/bin/java"
chmod 0755 "$runtime/bin/java"
echo "Restored bundled java launcher at $runtime/bin/java"
fi
dpkg-deb -b "$tmpdir" "$deb"
rm -rf "$tmpdir"
echo "Patched successfully: $deb"
done
shell: bash
- name: Patch rpm (native-launcher bypass GH#563)
run: |
set -uo pipefail
shopt -s nullglob
rpms=(composeApp/build/compose/binaries/main/rpm/*.rpm)
[ ${#rpms[@]} -gt 0 ] || { echo "No rpm to patch"; exit 0; }
if ! sudo apt-get install -y rpmrebuild rpm >/dev/null 2>&1; then
echo "::warning::could not install rpmrebuild; dropping unpatched rpm(s) to avoid shipping the GH#563 crash"
rm -f "${rpms[@]}"
exit 0
fi
WRAPPER="$(pwd)/packaging/linux/github-store-launcher.sh"
OUTDIR="$(mktemp -d)"
for rpm in "${rpms[@]}"; do
echo "Patching: $rpm"
# Overwrite the native launcher in-place with the JVM-direct wrapper, and
# restore bin/java (Compose strips native commands from the runtime, GH#746).
# rpmrebuild repopulates %files from the buildroot, so the new file ships.
if rpmrebuild --change-files="cp '$WRAPPER' ./opt/komi-store/bin/Komi-Store; chmod 0755 ./opt/komi-store/bin/Komi-Store; mkdir -p ./opt/komi-store/lib/runtime/bin; cp '$JAVA_HOME/bin/java' ./opt/komi-store/lib/runtime/bin/java; chmod 0755 ./opt/komi-store/lib/runtime/bin/java" \
-p -n -d "$OUTDIR" "$rpm"; then
rebuilt="$(find "$OUTDIR" -name '*.rpm' -newer "$WRAPPER" | head -n1 || true)"
if [ -n "$rebuilt" ]; then
mv -f "$rebuilt" "$rpm"
echo "Patched successfully: $rpm"
else
echo "::warning::rpmrebuild produced no output for $rpm; dropping it to avoid shipping the GH#563 crash"
rm -f "$rpm"
fi
else
echo "::warning::rpmrebuild failed for $rpm; dropping it to avoid shipping the GH#563 crash"
rm -f "$rpm"
fi
rm -f "$OUTDIR"/*/*.rpm 2>/dev/null || true
done
rm -rf "$OUTDIR"
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
if-no-files-found: error
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/Komi-Store-x86_64.AppImage
composeApp/build/compose/binaries/main/Komi-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/Komi-Store \
composeApp/build/compose/binaries/main/app-image/Komi-Store; do
if [ -f "$candidate/bin/Komi-Store" ]; then
APP_ROOT="$candidate"
break
fi
done
# Fall back to the AppDir we created earlier if still present
if [ -z "$APP_ROOT" ] && [ -f "Komi-Store.AppDir/bin/Komi-Store" ]; then
APP_ROOT="Komi-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/"
# Install the JVM-direct launcher that bypasses the jpackage native
# launcher (crashes in glibc setenv() on glibc >= 2.42, see GH#563).
cp packaging/linux/github-store-launcher.sh "$PKG_DIR/opt/github-store/bin/github-store-launcher.sh"
chmod +x "$PKG_DIR/opt/github-store/bin/github-store-launcher.sh"
# Launcher symlink
mkdir -p "$PKG_DIR/usr/bin"
ln -s "/opt/github-store/bin/github-store-launcher.sh" "$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=Komi Store
Exec=/opt/github-store/bin/github-store-launcher.sh
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/Komi-Store.png" ]; then
mkdir -p "$PKG_DIR/usr/share/icons/hicolor/256x256/apps"
cp "$PKG_DIR/opt/github-store/lib/Komi-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: [sign-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
# Snapshot of what download-artifact@v4 actually produced β€”
# useful when build-output paths drift in the future.
# upload-artifact@v4 strips the longest common prefix, so paths
# like main/exe/*.exe + main/msi/*.msi land as exe/*.exe and
# msi/*.msi inside the artifact. Fixed shallow globs miss them;
# we use `find` below for resilience.
echo "=== Downloaded artifact tree ==="
find artifacts -maxdepth 4 -type f 2>/dev/null | sort
echo
# 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
windows_portable_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). Files come from the
# signed artifact, not the raw build output, so they carry the
# SignPath Authenticode signature.
while IFS= read -r f; do
[ -n "$f" ] || continue
stage "$f" "$(basename "$f")" && windows_count=$((windows_count + 1)) || true
done < <(find artifacts/windows-installers-signed -type f \( -name '*.exe' -o -name '*.msi' \) 2>/dev/null)
# Windows portable β€” app-image zip from build-windows (unsigned, not
# routed through SignPath). Filename already carries version + arch.
while IFS= read -r f; do
[ -n "$f" ] || continue
stage "$f" "$(basename "$f")" && windows_portable_count=$((windows_portable_count + 1)) || true
done < <(find artifacts/windows-portable -type f -name '*.zip' 2>/dev/null)
# macOS β€” disambiguate x64 vs arm64 (Compose outputs identical filenames per arch)
while IFS= read -r f; do
[ -n "$f" ] || continue
base="$(basename "$f")"
ext="${base##*.}"
stem="${base%.*}"
stage "$f" "${stem}-x64.${ext}" && macos_x64_count=$((macos_x64_count + 1)) || true
done < <(find artifacts/macos-installers-x64 -type f \( -name '*.dmg' -o -name '*.pkg' \) 2>/dev/null)
while IFS= read -r f; do
[ -n "$f" ] || continue
base="$(basename "$f")"
ext="${base##*.}"
stem="${base%.*}"
stage "$f" "${stem}-arm64.${ext}" && macos_arm64_count=$((macos_arm64_count + 1)) || true
done < <(find artifacts/macos-installers-arm64 -type f \( -name '*.dmg' -o -name '*.pkg' \) 2>/dev/null)
# Linux modern β€” default Debian/RPM (unprefixed)
while IFS= read -r f; do
[ -n "$f" ] || continue
stage "$f" "$(basename "$f")" && linux_modern_count=$((linux_modern_count + 1)) || true
done < <(find artifacts/linux-installers-modern -type f \( -name '*.deb' -o -name '*.rpm' \) 2>/dev/null)
# Linux debian12-compat β€” suffix to avoid collision with modern
while IFS= read -r f; do
[ -n "$f" ] || continue
base="$(basename "$f")"
ext="${base##*.}"
stem="${base%.*}"
stage "$f" "${stem}-debian12.${ext}" && linux_debian12_count=$((linux_debian12_count + 1)) || true
done < <(find artifacts/linux-installers-debian12-compat -type f \( -name '*.deb' -o -name '*.rpm' \) 2>/dev/null)
# Linux AppImage + zsync (filenames already include -x86_64)
while IFS= read -r f; do
[ -n "$f" ] || continue
stage "$f" "$(basename "$f")" && linux_appimage_count=$((linux_appimage_count + 1)) || true
done < <(find artifacts/linux-appimage -type f \( -name '*.AppImage' -o -name '*.AppImage.zsync' \) 2>/dev/null)
# Linux Arch (.pkg.tar.zst already has version + arch in filename)
while IFS= read -r f; do
[ -n "$f" ] || continue
stage "$f" "$(basename "$f")" && linux_arch_count=$((linux_arch_count + 1)) || true
done < <(find artifacts/linux-arch -type f -name '*.pkg.tar.zst' 2>/dev/null)
echo
echo "Final staged files:"
ls -la release-files/
echo
echo "Per-group counts: windows=$windows_count windows-portable=$windows_portable_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)")
[ "$windows_portable_count" -eq 0 ] && missing+=("Windows portable (.zip)")
[ "$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