From b13dbc1af963fc969a28d43fb38d300fafb4248c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 30 Apr 2026 21:04:10 +0500 Subject: [PATCH 1/2] Add draft release job to build-desktop-platforms workflow Automates the assembly and upload of desktop installers to GitHub Releases. The new `release` job triggers after platform build completion, resolving the version from `gradle/libs.versions.toml`. It handles filename collisions by suffixing macOS binaries with architecture types (`-x64`, `-arm64`) and Linux compatibility builds with `-debian12`. The job uses `gh release create` (or `upload --clobber` if the tag exists) to manage a draft release containing the full suite of installers (EXE, MSI, DMG, PKG, DEB, RPM, AppImage, and Arch). --- .github/workflows/build-desktop-platforms.yml | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/.github/workflows/build-desktop-platforms.yml b/.github/workflows/build-desktop-platforms.yml index 7235817b0..02cbbcb81 100644 --- a/.github/workflows/build-desktop-platforms.yml +++ b/.github/workflows/build-desktop-platforms.yml @@ -411,3 +411,117 @@ jobs: 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() { + local src="$1" + local target_name="$2" + if [ -f "$src" ]; then + cp "$src" "release-files/$target_name" + echo "Staged: $target_name" + fi + } + + # Windows — names already unique (.exe, .msi) + for f in artifacts/windows-installers/*.exe artifacts/windows-installers/*.msi; do + [ -f "$f" ] && stage "$f" "$(basename "$f")" + 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}" + 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}" + done + + # Linux modern — default Debian/RPM (unprefixed) + for f in artifacts/linux-installers-modern/*.deb artifacts/linux-installers-modern/*.rpm; do + [ -f "$f" ] && stage "$f" "$(basename "$f")" + 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}" + done + + # Linux AppImage + zsync (filenames already include -x86_64) + for f in artifacts/linux-appimage/*.AppImage artifacts/linux-appimage/*.AppImage.zsync; do + [ -f "$f" ] && stage "$f" "$(basename "$f")" + done + + # Linux Arch (.pkg.tar.zst already has version + arch in filename) + for f in artifacts/linux-arch/*.pkg.tar.zst; do + [ -f "$f" ] && stage "$f" "$(basename "$f")" + done + + echo + echo "Final staged files:" + ls -la release-files/ + 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 + echo "Release $TAG already exists — uploading/replacing assets via --clobber..." + gh release upload "$TAG" release-files/* --clobber + else + echo "Creating new draft release $TAG..." + gh release create "$TAG" \ + --draft \ + --title "$VERSION" \ + --generate-notes \ + release-files/* + fi + shell: bash From 401f5a74ac91ad2e1aefa61898ba771baab4b5ea Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 30 Apr 2026 21:33:07 +0500 Subject: [PATCH 2/2] Add artifact completeness guard and draft-only clobbering to desktop CI The staging script in `build-desktop-platforms.yml` now tracks counts for each platform group (Windows, macOS x64/arm64, and five Linux variants). A new completeness guard fails the build if any group produces zero artifacts, preventing silent regressions where a specific installer type (like AppImage or macOS-arm64) is missing from the final release. The release upload step is also hardened to check the `isDraft` status of an existing tag via the GitHub CLI. It will only `--clobber` assets if the release is still a draft; if the release is already published, the workflow exits with an error to prevent accidental overwriting of stable artifacts. This forces a version bump in `gradle/libs.versions.toml` to roll a new release tag. --- .github/workflows/build-desktop-platforms.yml | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-desktop-platforms.yml b/.github/workflows/build-desktop-platforms.yml index 02cbbcb81..c5901057c 100644 --- a/.github/workflows/build-desktop-platforms.yml +++ b/.github/workflows/build-desktop-platforms.yml @@ -447,18 +447,32 @@ jobs: 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" ] && stage "$f" "$(basename "$f")" + [ -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) @@ -467,19 +481,20 @@ jobs: base="$(basename "$f")" ext="${base##*.}" stem="${base%.*}" - stage "$f" "${stem}-x64.${ext}" + 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}" + 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" ] && stage "$f" "$(basename "$f")" + [ -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 @@ -488,22 +503,49 @@ jobs: base="$(basename "$f")" ext="${base##*.}" stem="${base%.*}" - stage "$f" "${stem}-debian12.${ext}" + 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" ] && stage "$f" "$(basename "$f")" + [ -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" ] && stage "$f" "$(basename "$f")" + [ -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 @@ -514,8 +556,23 @@ jobs: run: | set -euo pipefail if gh release view "$TAG" >/dev/null 2>&1; then - echo "Release $TAG already exists — uploading/replacing assets via --clobber..." - gh release upload "$TAG" release-files/* --clobber + # 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" \