From c42866160123a83ce33b14a58d37bd6dbec18eac Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 16 Apr 2026 14:54:51 +0300 Subject: [PATCH] feat(release): expand desktop distribution to 8 assets + 2 package managers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 3, 4, 5, 6 of the multi-platform distribution plan. ## create-release.yml rewrite - Replace deprecated actions/create-release@v1 + upload-release-asset@v1 with softprops/action-gh-release@v2 (SHA-pinned) - Expand build-desktop matrix: macos-13 (Intel), macos-14 (ARM), windows-latest, ubuntu-latest × 2 legs (deb+rpm, AppImage+tar.gz) - Each matrix job uploads directly to release (no artifact round-trip — saves ~10 min + 1.5GB transfer per run) - Inline portable archives: Windows .zip via 7z, Linux .tar.gz via tar - linuxdeploy SHA-verified fetch for AppImage builds (not `continuous` tag) - Per-asset size budget: hard fail at 1 GB per asset - prerelease inferred from tag regex (-rc|-beta|-alpha|-dev|-snapshot) - workflow_dispatch dry_run input: builds all assets without publishing, skips Android + bump workflows - Tag-vs-libs.versions.toml assertion as first step in each matrix job - Android + Quartz jobs preserved; migrated to softprops/action-gh-release@v2 ## Package manager bump workflows (Homebrew + Winget) - .github/workflows/bump-homebrew.yml — macauley/action-homebrew-bump-cask on ubuntu-latest (saves macOS runner quota). Cask name: `amethyst-nostr`. - .github/workflows/bump-winget.yml — vedantmgoyal9/winget-releaser on windows-latest. PackageIdentifier: `VitorPamplona.Amethyst`. - Shared composite action .github/actions/assert-stable-release rejects draft/prerelease/malformed-tag releases at action boundary (defense in depth vs workflow-level `if:` alone). - Both workflows auto-open `release-ops`-labeled GH Issues on failure. - Concurrency groups per tag prevent re-fire races. - AUR + Scoop deferred to follow-up PR (unresolved ownership questions). ## Documentation - BUILDING.md: per-platform build commands, asset naming contract, release runbook, bootstrap runbook, troubleshooting, uninstall paths, incident response, fallback plans (macos-13 retirement, Homebrew Sept 2026 deadline) - README: expanded Download section with per-OS install matrix for 7 formats + 2 package managers. Deploying section points at BUILDING.md. ## Supply chain - .github/dependabot.yml: monthly bumps for github-actions ecosystem - All new third-party actions SHA-pinned: - softprops/action-gh-release v2.6.2 - macauley/action-homebrew-bump-cask v4.0.0 - vedantmgoyal9/winget-releaser v2 - nick-fields/retry v3.0.2 - linuxdeploy binary SHA256-verified against pinned release tag --- .../actions/assert-stable-release/action.yml | 44 ++ .github/dependabot.yml | 17 + .github/workflows/bump-homebrew.yml | 68 +++ .github/workflows/bump-winget.yml | 65 +++ .github/workflows/create-release.yml | 430 +++++++++--------- BUILDING.md | 395 ++++++++++++++++ README.md | 48 +- 7 files changed, 840 insertions(+), 227 deletions(-) create mode 100644 .github/actions/assert-stable-release/action.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/bump-homebrew.yml create mode 100644 .github/workflows/bump-winget.yml create mode 100644 BUILDING.md diff --git a/.github/actions/assert-stable-release/action.yml b/.github/actions/assert-stable-release/action.yml new file mode 100644 index 000000000..85358cb06 --- /dev/null +++ b/.github/actions/assert-stable-release/action.yml @@ -0,0 +1,44 @@ +name: Assert Stable Release +description: >- + Defense-in-depth guard for package-manager bump workflows. Re-validates + tag format, prerelease flag, and draft status before invoking third-party + actions that hold write credentials to external package manager repos + (Homebrew, Winget, AUR, Scoop). Prevents RC builds from reaching stable + channels even if the `release.released` event gating is bypassed. + +runs: + using: composite + steps: + - name: Assert release is stable + shell: bash + env: + TAG: ${{ github.event.release.tag_name }} + IS_PRERELEASE: ${{ github.event.release.prerelease }} + IS_DRAFT: ${{ github.event.release.draft }} + run: | + set -euo pipefail + echo "tag=$TAG prerelease=$IS_PRERELEASE draft=$IS_DRAFT" + + # Reject prerelease suffix even if GitHub's flag says false. + if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then + echo "::error::Tag $TAG contains prerelease suffix; refusing bump" + exit 1 + fi + + # Enforce strict vMAJOR.MINOR.PATCH format. + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Tag $TAG does not match vMAJOR.MINOR.PATCH" + exit 1 + fi + + # Draft releases must never trigger bumps. + if [[ "$IS_DRAFT" == "true" ]]; then + echo "::error::Release is draft; refusing bump" + exit 1 + fi + + # Prerelease flag cross-check (belt-and-suspenders with workflow-level `if:`). + if [[ "$IS_PRERELEASE" == "true" ]]; then + echo "::error::Release is prerelease; refusing bump" + exit 1 + fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0c0bdba38 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + # Auto-update SHA-pinned GitHub Actions across all workflows. + # Required for supply-chain safety — SHA pins only age well with active bumps. + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - release-ops + - dependencies + commit-message: + prefix: chore(actions) + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml new file mode 100644 index 000000000..993ffba5d --- /dev/null +++ b/.github/workflows/bump-homebrew.yml @@ -0,0 +1,68 @@ +name: Bump Homebrew Cask + +# Fires when a GH Release is published (not draft, not prerelease). +# `release.types: [released]` event fires only for stable releases — still +# double-checked by .github/actions/assert-stable-release for defense-in-depth. +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to bump (for manual recovery)' + required: true + type: string + +permissions: + contents: read + +concurrency: + # Serialize bumps per tag; do not cancel in-progress bumps. + group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + bump: + if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Re-assert stable release + if: github.event_name == 'release' + uses: ./.github/actions/assert-stable-release + + - name: Bump cask (push-or-update PR) + uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + token: ${{ secrets.HOMEBREW_TOKEN }} + tap: homebrew/cask + cask: amethyst-nostr + tag: ${{ github.event.release.tag_name || inputs.tag }} + + - name: Report failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[release-ops] bump-homebrew failed for ${tag}`, + body: [ + `Homebrew cask bump failed for release \`${tag}\`.`, + ``, + `- Run: ${runUrl}`, + `- Channel: Homebrew Cask (\`amethyst-nostr\`)`, + ``, + `Recovery options:`, + `1. Re-run the workflow once underlying issue is fixed`, + `2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``, + `3. File PR directly against Homebrew/homebrew-cask` + ].join('\n'), + labels: ['release-ops', 'bug'] + }); diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml new file mode 100644 index 000000000..06a806a2f --- /dev/null +++ b/.github/workflows/bump-winget.yml @@ -0,0 +1,65 @@ +name: Bump Winget Manifest + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to submit (for manual recovery)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: bump-winget-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + bump: + if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Re-assert stable release + if: github.event_name == 'release' + uses: ./.github/actions/assert-stable-release + + - name: Submit manifest to winget-pkgs + uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: VitorPamplona.Amethyst + version: ${{ github.event.release.tag_name || inputs.tag }} + # Asset naming contract: scripts/asset-name.sh + installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$' + token: ${{ secrets.WINGET_TOKEN }} + + - name: Report failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[release-ops] bump-winget failed for ${tag}`, + body: [ + `Winget manifest submission failed for release \`${tag}\`.`, + ``, + `- Run: ${runUrl}`, + `- Channel: Winget (\`VitorPamplona.Amethyst\`)`, + ``, + `Recovery options:`, + `1. Re-run the workflow once underlying issue is fixed`, + `2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``, + `3. File PR directly against microsoft/winget-pkgs` + ].join('\n'), + labels: ['release-ops', 'bug'] + }); diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index fc619bc3f..9a334fc8f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -3,31 +3,192 @@ name: Create Release Assets on: push: tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run: build assets but do not publish to GH Release or trigger bump workflows' + type: boolean + default: false + test_tag: + description: 'Synthetic tag name for dry-run (e.g. v1.08.0-dryrun); ignored on tag push' + type: string + default: 'v0.0.0-dryrun' permissions: contents: write -jobs: - create-release: - runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - steps: - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: false - prerelease: true +env: + # Asset naming contract: amethyst-desktop---. + # Single source of truth in scripts/asset-name.sh. + # linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below. + LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage + LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0 +jobs: + # --------------------------------------------------------------------------- + # Desktop build matrix. Each leg uploads directly to the GH Release via + # softprops/action-gh-release@v2 (upsert by tag_name). No artifact round-trip. + # --------------------------------------------------------------------------- + build-desktop: + strategy: + fail-fast: false + matrix: + include: + - { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" } + - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } + - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } + - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Resolve tag + version + id: ver + env: + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + TEST_TAG: ${{ github.event.inputs.test_tag || '' }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + TAG="${TEST_TAG:-v0.0.0-dryrun}" + else + TAG="${GITHUB_REF_NAME}" + fi + VER="${TAG#v}" + TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) + # On dry-run we only require that TOML has a version; on real tag push we require exact match. + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + if [[ "$TOML_VER" != "$VER" ]]; then + echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG" + exit 1 + fi + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "toml=$TOML_VER" >> "$GITHUB_OUTPUT" + + - name: Install RPM tooling (linux families) + if: startsWith(matrix.family, 'linux') + run: sudo apt-get update && sudo apt-get install -y rpm fakeroot + + - name: Fetch linuxdeploy (linux-portable only, SHA-verified) + if: matrix.family == 'linux-portable' + run: | + set -euo pipefail + curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o packaging/appimage/linuxdeploy-x86_64.AppImage + actual=$(sha256sum packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}') + if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then + echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual" + exit 1 + fi + chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage + # linuxdeploy needs FUSE; on newer runners, --appimage-extract-and-run is required. + # Bypass FUSE requirement by pre-extracting the AppImage. + (cd packaging/appimage && ./linuxdeploy-x86_64.AppImage --appimage-extract >/dev/null && \ + mv squashfs-root linuxdeploy-extracted && \ + ln -sf linuxdeploy-extracted/AppRun linuxdeploy-x86_64.bin && \ + chmod +x linuxdeploy-x86_64.bin) + + - name: Build desktop artifacts + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 + with: + max_attempts: 2 + timeout_minutes: 40 + command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }} + + - name: Build portable archives (windows + linux-portable) + if: matrix.family == 'windows' || matrix.family == 'linux-portable' + run: | + set -euo pipefail + VER="${{ steps.ver.outputs.version }}" + APP="desktopApp/build/compose/binaries/main-release/app" + mkdir -p desktopApp/build/portable + if [[ "${{ matrix.family }}" == "windows" ]]; then + ( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ ) + else + ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) + fi + + - name: Collect + rename assets + run: | + set -euo pipefail + # shellcheck source=scripts/asset-name.sh + source scripts/asset-name.sh + # linux-portable family holds AppImage + tar.gz, but we re-tag with plain `linux` for the asset name. + FAMILY="${{ matrix.family }}" + [[ "$FAMILY" == "linux-portable" ]] && FAMILY="linux" + mkdir -p dist + collect_assets "$FAMILY" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + + - name: Enforce asset size budget (1 GB per asset) + run: | + set -euo pipefail + fail=0 + for f in dist/*; do + if [[ -f "$f" ]]; then + size=$(wc -c < "$f") + mb=$(( size / 1048576 )) + if (( size > 1073741824 )); then + echo "::error file=$f::asset is ${mb} MB — exceeds 1 GB budget" + fail=1 + else + echo "OK: $f — ${mb} MB" + fi + fi + done + [[ "$fail" == 0 ]] + + - name: Classify release + id: classify + run: | + TAG="${{ steps.ver.outputs.tag }}" + if [[ "$TAG" =~ -(rc|beta|alpha|dev|dryrun|snapshot) ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload to GH Release (skip on dry-run) + if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + files: dist/* + tag_name: ${{ steps.ver.outputs.tag }} + prerelease: ${{ steps.classify.outputs.prerelease }} + draft: false + fail_on_unmatched_files: true + generate_release_notes: true + + - name: Dry-run summary + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' + run: | + echo "### Dry-run: ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + ls -la dist >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + # --------------------------------------------------------------------------- + # Android build + sign + direct-upload. Logic preserved from previous workflow; + # uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset. + # --------------------------------------------------------------------------- deploy-android: - needs: create-release + if: github.event_name != 'workflow_dispatch' # dry-run skips Android (tag-push only) runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code uses: actions/checkout@v6 @@ -44,9 +205,9 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: ${{ runner.os }}-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | - ${{ runner.os }}-gradle- + ${{ runner.os }}-android-gradle- - name: Build AAB run: ./gradlew clean bundleRelease --stacktrace @@ -98,141 +259,50 @@ jobs: env: BUILD_TOOLS_VERSION: "36.0.0" - # Google Play APK - - name: Upload Play APK Universal Asset - id: upload-release-asset-play-universal-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk - asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk - asset_content_type: application/zip + - name: Collect Android assets (rename to canonical scheme) + run: | + set -euo pipefail + mkdir -p dist + TAG="${GITHUB_REF_NAME}" - - name: Upload Play APK x86 Asset - id: upload-release-asset-play-x86-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk - asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk - asset_content_type: application/zip + # Play APKs (5 variants) + for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do + cp "amethyst/build/outputs/apk/play/release/amethyst-play-${variant}-release-unsigned-signed.apk" \ + "dist/amethyst-googleplay-${variant}-${TAG}.apk" + done - - name: Upload Play APK x86_64 Asset - id: upload-release-asset-play-x86-64-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk - asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk - asset_content_type: application/zip + # F-Droid APKs (5 variants) + for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do + cp "amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-${variant}-release-unsigned-signed.apk" \ + "dist/amethyst-fdroid-${variant}-${TAG}.apk" + done - - name: Upload Play APK arm64-v8a Asset - id: upload-release-asset-play-arm64-v8a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk - asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk - asset_content_type: application/zip + # AABs + cp "amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab" \ + "dist/amethyst-googleplay-${TAG}.aab" + cp "amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab" \ + "dist/amethyst-fdroid-${TAG}.aab" + ls -la dist - - name: Upload Play APK armeabi-v7a Asset - id: upload-release-asset-play-armeabi-v7a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk - asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk - asset_content_type: application/zip + - name: Classify release + id: classify + run: | + TAG="${GITHUB_REF_NAME}" + if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi - # F-Droid APK - - name: Upload F-Droid APK Universal Asset - id: upload-release-asset-fdroid-universal-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Android assets to GH Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk - asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK x86 Asset - id: upload-release-asset-fdroid-x86-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk - asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK x86_64 Asset - id: upload-release-asset-fdroid-x86-64-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk - asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK arm64-v8a Asset - id: upload-release-asset-fdroid-arm64-v8a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk - asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK armeabi-v7a Asset - id: upload-release-asset-fdroid-armeabi-v7a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk - asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk - asset_content_type: application/zip - - # Google Play AAB - - name: Upload Google Play AAB Asset - id: upload-release-asset-play-aab - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab - asset_name: amethyst-googleplay-${{ github.ref_name }}.aab - asset_content_type: application/zip - - # FDroid AAB - - name: Upload F-Droid AAB Asset - id: upload-release-asset-fdroid-aab - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab - asset_name: amethyst-fdroid-${{ github.ref_name }}.aab - asset_content_type: application/zip + files: dist/* + tag_name: ${{ github.ref_name }} + prerelease: ${{ steps.classify.outputs.prerelease }} + draft: false + fail_on_unmatched_files: true + generate_release_notes: true - name: Publish Quartz Lib run: ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache @@ -241,65 +311,3 @@ jobs: ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} - - deploy-desktop: - needs: create-release - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - task: packageDeb - format: deb - platform: linux - - os: macos-latest - task: packageDmg - format: dmg - platform: macos - - os: windows-latest - task: packageMsi - format: msi - platform: windows - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: 21 - - - name: Cache gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Build Desktop Distribution - run: ./gradlew :desktopApp:${{ matrix.task }} - - - name: Find distribution file - id: find-dist - run: | - DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1) - echo "path=$DIST_FILE" >> $GITHUB_OUTPUT - echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT - - - name: Upload Desktop Distribution to Release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ${{ steps.find-dist.outputs.path }} - asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }} - asset_content_type: application/octet-stream diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 000000000..611db4f12 --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,395 @@ +# Building Amethyst Desktop + +This guide covers building Amethyst Desktop from source, the release pipeline, +and one-time bootstrap steps for distribution channels. + +- [Prerequisites](#prerequisites) +- [Clone + first build](#clone--first-build) +- [Per-format build commands](#per-format-build-commands) +- [Asset naming contract](#asset-naming-contract) +- [Release runbook](#release-runbook) +- [Bootstrap runbook (one-time)](#bootstrap-runbook-one-time) +- [Troubleshooting installs](#troubleshooting-installs) +- [Uninstall + state paths](#uninstall--state-paths) +- [Incident response](#incident-response) +- [Fallback plans](#fallback-plans) + +--- + +## Prerequisites + +All platforms: + +- **JDK 21** (Zulu or Temurin recommended) +- **Git** + +Platform-specific: + +- **macOS**: Xcode Command Line Tools (`xcode-select --install`) +- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` +- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage + +Install Linux RPM tooling: + +```bash +# Debian/Ubuntu +sudo apt-get install -y rpm fakeroot + +# Fedora +sudo dnf install -y rpm-build +``` + +Install linuxdeploy locally (CI fetches its own — SHA-verified): + +```bash +curl -fsSL -o packaging/appimage/linuxdeploy-x86_64.AppImage \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage +chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage +``` + +--- + +## Clone + first build + +```bash +git clone https://github.com/vitorpamplona/amethyst.git +cd amethyst + +# Dev loop (launches Amethyst Desktop) +./gradlew :desktopApp:run + +# Package for current OS +./gradlew :desktopApp:packageDistributionForCurrentOS +``` + +--- + +## Per-format build commands + +| Artifact | Command | Output | +|---|---|---| +| macOS DMG (host arch) | `./gradlew :desktopApp:packageReleaseDmg` | `desktopApp/build/compose/binaries/main-release/dmg/Amethyst-*.dmg` | +| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` | +| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` | +| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` | +| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` | +| Windows `.zip` portable | See below (inline `7z`) | — | +| Linux `.tar.gz` portable | See below (inline `tar`) | — | + +**Inline portable archives** (run after `createReleaseDistributable`): + +```bash +./gradlew :desktopApp:createReleaseDistributable + +# Linux tar.gz +VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) +( cd desktopApp/build/compose/binaries/main-release/app \ + && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) + +# Windows .zip (PowerShell) +Compress-Archive -Path desktopApp\build\compose\binaries\main-release\app\Amethyst ` + -DestinationPath "desktopApp\build\portable\amethyst-desktop-$env:VER-windows-x64.zip" +``` + +Cross-platform architecture note: **`jpackage` cannot cross-compile**. An Intel +DMG must be built on `macos-13` (x64); an ARM DMG must be built on `macos-14` +or later. CI runs both. + +--- + +## Asset naming contract + +All GH Release assets follow: + +``` +amethyst-desktop---. +``` + +Where: + +| Field | Values | +|---|---| +| `` | Tag stripped of leading `v` (e.g. `1.08.0`) | +| `` | `macos`, `windows`, `linux` | +| `` | `x64`, `arm64` | +| `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` | + +Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh). +Package manager manifests (Homebrew cask, Winget) depend on this exact scheme — +any change is a breaking contract. + +Examples: + +- `amethyst-desktop-1.08.0-macos-x64.dmg` +- `amethyst-desktop-1.08.0-macos-arm64.dmg` +- `amethyst-desktop-1.08.0-windows-x64.msi` +- `amethyst-desktop-1.08.0-linux-x64.AppImage` + +--- + +## Release runbook + +The release flow is driven by a tag push. Every cut ships Android + Desktop + +Quartz library in one pipeline. + +1. **Bump the app version** in `gradle/libs.versions.toml`: + + ```toml + [versions] + app = "1.08.1" # new semver + ``` + +2. **Bump Android `versionCode`** in `amethyst/build.gradle` (monotonic integer, + must increment even for same `versionName`): + + ```groovy + versionCode = 443 + versionName = generateVersionName(libs.versions.app.get()) + ``` + +3. **Commit + tag + push**: + + ```bash + git commit -am "chore(release): 1.08.1" + git tag -s v1.08.1 -m "Release 1.08.1" + git push && git push --tags + ``` + +4. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). + +5. **Verify**: + - GH Release contains 8 desktop assets + 12 Android assets + - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) + - Intel + ARM DMGs both present + - Android flow unchanged + +6. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`, + or `-snapshot` is auto-classified as prerelease. Stable tags trigger the + Homebrew + Winget bump workflows. + +### Dry-run (no tag push) + +Use `workflow_dispatch` to exercise the full matrix without publishing: + +```bash +gh workflow run create-release.yml \ + -f dry_run=true \ + -f test_tag=v0.0.0-dryrun \ + --ref feat/my-branch +``` + +Assets are built and size-checked, but not uploaded; bump workflows do not +fire. Use for pre-merge validation of workflow changes. + +### Version constraint: tag must match `libs.versions.toml` + +The first step in each build-desktop matrix job asserts: + +``` +tag (stripped of 'v') == gradle/libs.versions.toml [versions] app +``` + +If they drift, the workflow fails fast. Always bump the TOML first, then tag. + +### NEVER change Windows `upgradeUuid` + +`desktopApp/build.gradle.kts:upgradeUuid` is the MSI product family GUID. +Changing it breaks in-place upgrades for existing Windows users — they must +uninstall before a new release. Leave it alone forever. + +--- + +## Bootstrap runbook (one-time) + +### Secrets to provision in GitHub repo settings + +| Secret | Purpose | Scope | +|---|---|---| +| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry | +| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | + +All existing secrets (`SIGNING_KEY`, `SONATYPE_USERNAME`, etc.) remain +unchanged. + +Rotate both on a 90-day cadence. Owner: assigned via `docs/RELEASE_OPS.md` +or equivalent issue tracker. On rotation, paste new token and run +`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify. + +### Homebrew cask (one-time initial PR) + +```bash +brew bump-cask-pr amethyst-nostr \ + --version 1.08.0 \ + --url "https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-macos-arm64.dmg" +``` + +The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a +tiling window manager). After the first PR is merged, `bump-homebrew.yml` +auto-submits new version bumps on each stable release. + +### Winget (one-time initial submission) + +```bash +wingetcreate new \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-windows-x64.msi +``` + +Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is +merged into `microsoft/winget-pkgs`, `bump-winget.yml` auto-submits new +version manifests. + +--- + +## Troubleshooting installs + +### macOS — Gatekeeper "damaged and can't be opened" + +Amethyst Desktop is currently unsigned. First-time launch requires: + +1. **Right-click → Open** on the app (don't double-click) — then click **Open** on the Gatekeeper dialog +2. Or: `xattr -cr /Applications/Amethyst.app` to strip quarantine +3. Or: System Settings → Privacy & Security → "Open Anyway" after a blocked launch + +Recommended path: install via Homebrew (`brew install --cask amethyst-nostr`) +— cask flow handles this seamlessly. + +### Windows — SmartScreen "Windows protected your PC" + +Amethyst Desktop is currently unsigned (no Authenticode). First-time launch: + +1. Click **More info** on the SmartScreen dialog +2. Click **Run anyway** + +Alternatively use `winget install VitorPamplona.Amethyst` — winget install +bypasses the UI dialog after accepting the installer's inherent trust. + +### Linux AppImage won't execute + +```bash +chmod +x Amethyst-*.AppImage +./Amethyst-*.AppImage +``` + +On Fedora Silverblue / very minimal distros, FUSE might be missing. Use +`--appimage-extract-and-run`: + +```bash +./Amethyst-*.AppImage --appimage-extract-and-run +``` + +--- + +## Uninstall + state paths + +State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb, +.rpm, AppImage, tar.gz). Switching channels does not duplicate data but may +expose downgrade migration risks — **prefer a single install channel per +machine**. + +| OS | App location | State directories | +|---|---|---| +| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`
`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`
`~/Library/Caches/Amethyst` | +| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`
`%LOCALAPPDATA%\Amethyst` | +| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`
`~/.local/share/amethyst`
`~/.cache/amethyst` | +| Linux (AppImage/tar.gz) | user-chosen | Same as above | + +Uninstall: + +- Homebrew: `brew uninstall --cask amethyst-nostr && brew zap amethyst-nostr` +- Winget: `winget uninstall VitorPamplona.Amethyst` +- .deb: `sudo apt remove amethyst` +- .rpm: `sudo dnf remove amethyst` +- AppImage / tar.gz: delete the file / extracted directory +- macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually + +--- + +## Incident response + +### Bad GH Release asset + +1. Immediately mark release as prerelease (pauses bump workflows): + ```bash + gh release edit v1.08.1 --prerelease + ``` +2. Delete the bad asset: + ```bash + gh release delete-asset v1.08.1 amethyst-desktop-1.08.1-macos-arm64.dmg --yes + ``` +3. Rebuild locally or rerun the failing matrix job: + ```bash + gh run rerun --failed + ``` +4. Flip back to stable once verified (re-fires bump workflows — confirm fix first): + ```bash + gh release edit v1.08.1 --prerelease=false + ``` + +### Bad build reached Homebrew + +**Preferred**: ship a point release (e.g. v1.08.2) — users on v1.08.1 get the +fix via `brew upgrade`. + +**Alternative**: close the open PR in `Homebrew/homebrew-cask` before merge, +or file a revert PR if already merged. Typical Homebrew turn-around: 1–2 days. + +### Bad build reached Winget + +Winget manifests are append-only — no hard unpublish. Options: + +1. Ship a point release (preferred — users upgrade via `winget upgrade`) +2. File a manifest-removal PR against `microsoft/winget-pkgs`. Moderator + review: 24–72h. + +### User-facing communication + +On any incident: + +1. Edit the release body on GitHub with a warning banner + workaround +2. Pin a GH Issue with downgrade instructions per channel +3. Announce via Nostr relay + project social channels + +--- + +## Fallback plans + +### macOS Intel runner retirement + +GitHub's `macos-13` runner will eventually be deprecated. Monitor + +for the deprecation date. When it hits: + +1. Drop the `macos-13` matrix entry from `.github/workflows/create-release.yml` +2. Add a cross-arch build step on `macos-14` using a bundled x64 JDK + `jpackage --mac-signing-prefix` shenanigans, OR accept that only Apple Silicon DMGs ship and direct Intel users to `winget` on a Parallels VM or to rebuild from source. +3. Update README install matrix to reflect the change. + +### Homebrew main-cask rejects unsigned app (post-Sept 1 2026) + +Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask` +on 2026-09-01. Before that date: + +**Option A**: Commit budget to Apple Developer Program ($99/yr), add +`signing { sign.set(true) }` + `notarization {}` blocks to +`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI. + +**Option B**: Pivot to a private Homebrew tap: + +```bash +# Create repo: vitorpamplona/homebrew-amethyst +# Update bump-homebrew.yml: +# tap: vitorpamplona/amethyst +# cask: amethyst-nostr +# Users install: brew tap vitorpamplona/amethyst && brew install --cask amethyst-nostr +``` + +Note: a private tap does NOT bypass Gatekeeper itself (macOS OS-level) — users +still see the "unsigned developer" dialog. Tap only sidesteps Homebrew's +internal policy. + +--- + +## Follow-up channels (separate PRs) + +- **AUR** (`amethyst-desktop-bin`) — blocked on AUR account ownership decision +- **Scoop** (Windows) — blocked on bucket strategy (own vs Extras) +- **Flathub** — deferred (moderate ongoing maintenance) diff --git a/README.md b/README.md index dcf7f0753..bd5cc3036 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Join the social network you control. ## Download and Install +### Android + [Get it on Zap Store](https://github.com/zapstore/zapstore/releases) @@ -33,6 +35,24 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) alt="Get it on Google Play" height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst) +### Desktop + +| OS | CLI install | Direct download | +|---|---|---| +| macOS (Apple Silicon) | `brew install --cask amethyst-nostr` | [.dmg arm64](https://github.com/vitorpamplona/amethyst/releases/latest) | +| macOS (Intel) | `brew install --cask amethyst-nostr` | [.dmg x64](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Windows 10/11 | `winget install VitorPamplona.Amethyst` | [.msi](https://github.com/vitorpamplona/amethyst/releases/latest) · [.zip portable](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Debian/Ubuntu | — | [.deb](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Fedora/RHEL/openSUSE | — | [.rpm](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Any Linux | — | [AppImage](https://github.com/vitorpamplona/amethyst/releases/latest) · [.tar.gz](https://github.com/vitorpamplona/amethyst/releases/latest) | + +_Coming soon (separate PR): Scoop (Windows), AUR (Arch Linux)._ + +**Build from source:** see [BUILDING.md](BUILDING.md). + +**Install troubleshooting** (Gatekeeper / SmartScreen / AppImage): see +[BUILDING.md § Troubleshooting installs](BUILDING.md#troubleshooting-installs). + ## Supported Features @@ -249,22 +269,18 @@ For the Play build: ## Deploying -1. Generate a new signing key -``` -keytool -genkey -v -keystore -alias -keyalg RSA -keysize 2048 -validity 10000 -openssl base64 < | tr -d '\n' | tee some_signing_key.jks.base64.txt -``` -2. Create four Secret Key variables on your GitHub repository and fill in the signing key information - - `KEY_ALIAS` <- `` - - `KEY_PASSWORD` <- `` - - `KEY_STORE_PASSWORD` <- `` - - `SIGNING_KEY` <- the data from `` -3. Change the `versionCode` and `versionName` on `amethyst/build.gradle` -4. Commit and push. -5. Tag the commit with `v{x.x.x}` -6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file. -7. Add your CHANGE LOG to the description of the new release -8. Download the `aab` file and upload it to the PlayStore. +Full release + bootstrap runbooks (Android AAB upload, desktop packaging, +Homebrew cask, Winget manifest, Apple Developer signing budget time-box) live +in [BUILDING.md § Release runbook](BUILDING.md#release-runbook) and +[BUILDING.md § Bootstrap runbook (one-time)](BUILDING.md#bootstrap-runbook-one-time). + +TL;DR for cutting a release: + +1. Bump `app` in `gradle/libs.versions.toml` (e.g. `"1.08.1"`) +2. Bump `versionCode` in `amethyst/build.gradle` +3. `git commit -am "chore(release): 1.08.1" && git tag -s v1.08.1 && git push --tags` +4. Wait for `Create Release Assets` workflow — 20 Android assets + 8 desktop assets go live on GH Release; Homebrew + Winget auto-bump on stable tags +5. Upload AAB to Play Store manually (existing step) ## Using the Quartz library