Merge pull request #579 from OpenHub-Store/ci/signpath-windows-signing #44
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 | |
| 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 | |
| shell: bash | |
| - name: Upload Windows installers | |
| id: upload-windows | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: windows-installers | |
| path: | | |
| composeApp/build/compose/binaries/main/exe/*.exe | |
| composeApp/build/compose/binaries/main/msi/*.msi | |
| if-no-files-found: error | |
| retention-days: 30 | |
| compression-level: 6 | |
| sign-windows: | |
| name: Sign Windows installers (SignPath) | |
| needs: build-windows | |
| runs-on: ubuntu-latest | |
| permissions: | |
| id-token: write | |
| contents: read | |
| 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-version' | |
| 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 | |
| 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/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 | |
| 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/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: [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 | |
| 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) | |
| # 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 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 |