Files
amethyst/docs/plans/2026-04-16-feat-desktop-multiplatform-distribution-plan.md
T
nrobi144 da1037423c feat(desktop): version source-of-truth + RPM + AppImage packaging
Phase 1+2 of multi-platform distribution plan:
- gradle/libs.versions.toml: add `app = "1.08.0"` as single source of truth
- Root build.gradle: allprojects { version = libs.versions.app.get() }
- amethyst/build.gradle: versionName from catalog (versionCode stays local)
- desktopApp/build.gradle.kts: drop hardcoded "1.0.0"; inherit project.version;
  add TargetFormat.Rpm; add linux DSL (menuGroup, appCategory, debMaintainer,
  rpmLicenseType, rpmPackageVersion with dashes stripped)
- desktopApp/build.gradle.kts: new createReleaseAppImage task wrapping
  createReleaseDistributable with linuxdeploy (TargetFormat.AppImage is
  broken in Compose 1.10.x — CMP-7101)
- packaging/appimage/: AppRun launcher (sets LD_LIBRARY_PATH for bundled VLC),
  amethyst.desktop XDG entry, 512x512 icon extracted from icon.icns
- scripts/asset-name.sh: single source for release asset naming contract
2026-04-16 14:35:01 +03:00

65 KiB
Raw Blame History

title, type, status, date, origin, deepened
title type status date origin deepened
Desktop Multi-Platform Distribution feat active 2026-04-16 docs/brainstorms/2026-04-16-desktop-multiplatform-distribution-brainstorm.md 2026-04-16

Desktop Multi-Platform Distribution

Enhancement Summary (2026-04-16) — deepened via 10 parallel research agents. Scope refined based on findings:

  • Scope cut: AUR + Scoop deferred to follow-up PR. This PR ships Homebrew + Winget only (+ all 8 release assets).
  • Dropped: SHA256SUMS.txt aggregation (Amethyst Android releases have no checksums; follow existing convention — no cosign/GPG on release).
  • Dropped: draft→publish flip (current create-release.yml already uses direct single-shot publish with draft: false, prerelease: true; align with existing pattern).
  • Dropped: createPortableTarGz / createPortableZip Gradle tasks (inline tar/zip in CI after createReleaseDistributable).
  • Dropped: verify-version as separate job (merged as first step in each matrix job).
  • Dropped: 8 of 11 template files — Homebrew cask rewritten by action-homebrew-bump-cask from live cask; Winget manifests generated by winget-releaser. Only 3 build-input files retained for AppImage.
  • Added P0: SHA-pin all third-party GH Actions (tj-actions March 2025 precedent); verify appimagetool SHA256 or commit binary to repo; re-assert release.prerelease inside each bump workflow.
  • Added perf: Upload directly to release from matrix jobs (skip artifact round-trip — saves 8-12 min + 1.5GB double-transfer).
  • Added pattern: linuxdeploy instead of raw appimagetool for JVM+VLC library bundling; build AppImage on ubuntu-22.04 (glibc 2.35) for broad compat.
  • Resolved P0 blocker: VLC arm64 macOS concern was a false alarm — plugin fetches universal DMG; bundled dylibs already multi-arch. ARM DMG video playback is functional today.
  • Renamed: createAppImagecreateReleaseAppImage (aligns with Compose's createReleaseDistributable). Secret names HOMEBREW_PAT/WINGET_PATHOMEBREW_TOKEN/WINGET_TOKEN (matches existing SONATYPE_PASSWORD pattern).

Overview

Transform Amethyst Desktop's install story from "unsigned .deb/.msi/.dmg dumped on GH Releases" (only ARM-macOS, no Intel) into a multi-channel FOSS distribution: 8 release assets covering every mainstream desktop OS/arch, 2 auto-bumping package-manager channels (Homebrew + Winget), and an authoritative BUILDING.md. Ship as one PR. AUR and Scoop ship in a follow-up PR once maintainer resolves their open questions.

Carried from brainstorm (see brainstorm: docs/brainstorms/2026-04-16-desktop-multiplatform-distribution-brainstorm.md):

  • User choice over paternalism — multiple install paths documented; users pick.
  • FOSS alignment — no walled-garden stores (no Mac App Store, no MS Store, no Snap).
  • Low maintenance — every channel auto-pulls from GH Releases; no per-release manual submissions after one-time bootstrap.
  • Frictionless-where-possible without signing budget — Homebrew/Winget/Scoop/AUR CLI paths sidestep Gatekeeper/SmartScreen warnings without requiring signing.

Problem Statement

Current state (research-confirmed)

Concern Actual state Source
Desktop packageVersion Hardcoded 1.0.0 in desktopApp/build.gradle.kts:90; drift from Android 1.06.3 desktopApp/build.gradle.kts:90
macOS DMG arch Only ARM64 — macos-latest GH runner is arm64 since 2024. Intel users get unusable DMG .github/workflows/create-release.yml:260
Linux formats .deb only — no RPM, no AppImage, no tarball desktopApp/build.gradle.kts:87
Windows formats .msi only — no portable .zip desktopApp/build.gradle.kts:87
Install channels GH Releases direct download only. No Homebrew, Winget, Scoop, AUR .github/workflows/create-release.yml:264305
Install docs README §Download lists Android-only links (Zap Store, Obtainium, Play, GH). No desktop install section README.md:2236
Build docs No BUILDING.md, CONTRIBUTING.md, or RELEASING.md repo root
Version sync versionCode + versionName hardcoded in amethyst/build.gradle:5758; desktop hardcoded separately amethyst/build.gradle:5758
Release action Uses deprecated actions/create-release@v1 + actions/upload-release-asset@v1 (archived) .github/workflows/create-release.yml:19,297
SHA256 None published none
Prerelease gating All v* tags marked prerelease: true; no stable-vs-rc distinction .github/workflows/create-release.yml:25

Why this matters

  • Intel Mac users are currently broken — silently. Confirmed by research: macos-latest returns arm64, and jpackage cannot cross-compile.
  • Discovery bottleneck: only users who find GH Releases install at all. Package-manager users (the largest FOSS desktop segment — Homebrew has 30M+ users, Winget ships in Windows 11) never encounter Amethyst Desktop.
  • Trust friction: unsigned DMG on macOS triggers Gatekeeper ("damaged and can't be opened"); unsigned MSI triggers SmartScreen. Homebrew/Winget/Scoop CLI paths sidestep these warnings for CLI-comfortable users without requiring signing budget.
  • Deprecation risk: actions/create-release@v1 is archived; future GHA runner changes could break releases silently.
  • Version drift is visible: if we ship to Homebrew showing 1.0.0 while Android is 1.06.3, users perceive the project as abandoned.

Proposed Solution [REFINED after deepen]

A single large PR landing:

  1. Version source-of-truth in gradle/libs.versions.toml ([versions] app = "1.06.3"), consumed by Android + Desktop modules. Android versionCode stays locally bumped in amethyst/build.gradle; only versionName / packageVersion share the source. project.version set at root allprojects{} so subprojects inherit — avoids multi-module catalog-resolution drift.
  2. Expanded Gradle packaging in desktopApp/build.gradle.kts — add TargetFormat.Rpm; add one custom Gradle task createReleaseAppImage (AppImage via linuxdeploy wrapping createReleaseDistributable). Portable tar.gz/zip produced by inline tar/zip in CI after createReleaseDistributable — no Gradle task needed.
  3. Rewritten .github/workflows/create-release.yml — replace deprecated actions/create-release@v1 + actions/upload-release-asset@v1 with softprops/action-gh-release@v2 (SHA-pinned). Expand desktop matrix to macos-13 (Intel) + macos-14 (ARM) + windows-latest + ubuntu-latest. Matrix jobs upload directly to release via softprops/action-gh-release@v2 (no intermediate artifact round-trip — saves 812 min and 1.5GB double-transfer). Produce 8 desktop assets. No SHA256SUMS.txt (follows existing Amethyst convention — no checksums file on current releases). Release published directly (no draft→publish flip — follows existing create-release.yml:25 single-shot pattern).
  4. Two new auto-bump workflows (AUR + Scoop deferred):
    • .github/workflows/bump-homebrew.ymlaction-homebrew-bump-cask on ubuntu-latest (brew works on Linux; saves macOS runner quota)
    • .github/workflows/bump-winget.ymlvedantmgoyal9/winget-releaser on windows-latest
    • Both gated on release.types: [released] + if: github.event.release.prerelease == false at job level AND re-assert tag format (^v\d+\.\d+\.\d+$, rejecting -rc|-beta|-alpha) as first step at action boundary (defense-in-depth).
    • Both use workflow_run trigger variant where possible, gating on create-release workflow success.
  5. Minimal packaging/ tree — 3 files only:
    • packaging/appimage/AppRun — shell launcher script (for AppImage)
    • packaging/appimage/amethyst.desktop — XDG desktop entry (for AppImage)
    • packaging/appimage/amethyst.png — 512×512 icon (scaled from existing 100×100 icon.png)
    • Homebrew cask: lives in Homebrew/homebrew-cask after initial manual PR; action-homebrew-bump-cask re-fetches and rewrites it. No .tmpl in our repo.
    • Winget manifests: generated by winget-releaser from prior version on each release. No .tmpl in our repo.
  6. Composite action .github/actions/assert-stable-release/action.yml — shared prerelease + tag-format re-assertion, called by both bump workflows. Prevents drift across workflows.
  7. New BUILDING.md at repo root: prereqs, per-platform build commands, release runbook (maintainer-facing), bootstrap runbook (one-time), troubleshooting (Gatekeeper, SmartScreen), uninstall + state paths per OS.
  8. README install section rewritten: per-OS install matrix with CLI + direct-download paths. AUR/Scoop rows marked "Coming soon (separate PR)".

Technical Approach

Architecture [REFINED]

┌────────────────────────────────────────────────────────────────────────────┐
│                          gradle/libs.versions.toml                          │
│                          [versions] app = "1.06.3"                          │
└──────────────────┬──────────────────────────────────┬──────────────────────┘
                   │                                  │
     ┌─────────────▼─────────────┐    ┌───────────────▼──────────────┐
     │  amethyst/build.gradle    │    │ desktopApp/build.gradle.kts  │
     │  versionName = libs...    │    │ project.version inherited    │
     │  versionCode = 435 (local)│    │ packageVersion = project.ver │
     └───────────────────────────┘    └──────────────────────────────┘
                   │                                  │
                   ▼                                  ▼
   ┌──────────────────────────────────────────────────────────────────────┐
   │          .github/workflows/create-release.yml (rewritten)            │
   │   Trigger: push tag v*                                               │
   │   build-desktop (4-way matrix):                                      │
   │     macos-13        → packageReleaseDmg          (Intel .dmg)        │
   │     macos-14        → packageReleaseDmg          (ARM .dmg)          │
   │     windows-latest  → packageReleaseMsi + inline `zip` portable      │
   │     ubuntu-latest   → packageReleaseDeb + packageReleaseRpm          │
   │                     + createReleaseAppImage + inline `tar` portable  │
   │   Each matrix job uploads DIRECTLY to release via                    │
   │   softprops/action-gh-release@v2 (no artifact round-trip)            │
   │   (android + quartz jobs unchanged)                                  │
   │   release-finalize job (needs: build-desktop, deploy-android):       │
   │     - sets prerelease flag (inferred from tag: -rc/-beta/-alpha)     │
   │     - auto-generated release notes                                   │
   │     - direct single-shot publish (no draft flip — matches existing   │
   │       create-release.yml:25 pattern)                                 │
   │     - no SHA256SUMS.txt (follows existing Amethyst convention)       │
   └──────────────────────────┬─────────────────────────────┬─────────────┘
                              │  release.released event      │
                              │  (stable tags only —         │
                              │   prerelease == false +      │
                              │   tag re-asserted)           │
                              ▼                              ▼
                   ┌─────────────────────┐    ┌─────────────────────┐
                   │ bump-homebrew.yml   │    │ bump-winget.yml     │
                   │ ubuntu-latest       │    │ windows-latest      │
                   │ action-homebrew-    │    │ vedantmgoyal9/      │
                   │ bump-cask           │    │ winget-releaser     │
                   └─────────────────────┘    └─────────────────────┘

   [FOLLOW-UP PR]: bump-aur.yml + bump-scoop.yml once AUR owner +
   Scoop bucket strategy decided (brainstorm Open Q1, Q2)

Implementation Phases

All phases land as one PR. Phases are logical groupings within the PR for reviewer clarity.

Phase 1 — Version source-of-truth (foundation)

Files:

  • gradle/libs.versions.toml — add app = "1.06.3" under [versions]
  • amethyst/build.gradle — read versionName from catalog
  • desktopApp/build.gradle.kts — read packageVersion from catalog; also set rpmPackageVersion with dashes stripped (RPM constraint)
  • Root build.gradle — optionally set allprojects { version = libs.versions.app.get() }

Pseudo-code (desktopApp/build.gradle.kts):

// desktopApp/build.gradle.kts
val appVersion = libs.versions.app.get()
val appVersionRpm = appVersion.substringBefore("-")  // RPM forbids '-'

project.version = appVersion

compose.desktop {
    application {
        nativeDistributions {
            targetFormats(
                TargetFormat.Dmg,
                TargetFormat.Msi,
                TargetFormat.Deb,
                TargetFormat.Rpm,
            )
            packageName = "Amethyst"
            packageVersion = appVersion
            linux {
                iconFile.set(project.file("src/jvmMain/resources/icon.png"))
                rpmPackageVersion = appVersionRpm
                menuGroup = "Network"
                appCategory = "Network"
                debMaintainer = "Amethyst Contributors <contact@amethyst>"  // open question: email
                rpmLicenseType = "MIT"
            }
            // ... existing macOS + windows blocks unchanged
        }
    }
}

amethyst/build.gradle wiring:

// amethyst/build.gradle:57-58 replacement
def appVersion = libs.versions.app.get()
versionCode = 435           // bumped manually per release (Android requirement)
versionName = generateVersionName(appVersion)  // keep branch-suffix logic

Verification: ./gradlew :desktopApp:packageDistributionForCurrentOS produces an asset named Amethyst-1.06.3.* (not Amethyst-1.0.0.*).

Phase 2 — Expanded Gradle packaging

New Gradle tasks in desktopApp/build.gradle.kts:

  1. RPM — add TargetFormat.Rpm to targetFormats list (done in Phase 1 pseudo-code above). Compose will generate packageReleaseRpm task. Ubuntu runner needs apt-get install -y rpm pre-step.

  2. AppImage — custom task createReleaseAppImage. TargetFormat.AppImage in Compose 1.10.x is broken (CMP-7101) — do NOT use. Use linuxdeploy (not raw appimagetool) because it auto-scans usr/lib/ for missing libraries, handles rpath for bundled JVM, and bundles VLC .so files reliably:

val createReleaseAppImage by tasks.registering(Exec::class) {
    group = "compose desktop"
    dependsOn("createReleaseDistributable")

    val distDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Amethyst")
    val appDir = layout.buildDirectory.dir("appimage/Amethyst.AppDir")
    val outFile = layout.buildDirectory.file("appimage/Amethyst-${project.version}-x86_64.AppImage")
    val toolRoot = layout.projectDirectory.dir("packaging/appimage")

    inputs.dir(distDir)
    inputs.dir(toolRoot)
    outputs.file(outFile)

    doFirst {
        val dir = appDir.get().asFile
        dir.deleteRecursively()
        dir.mkdirs()
        copy {
            from(distDir) { into("usr") }
            from(toolRoot.file("AppRun")) { rename { "AppRun" }; fileMode = 0b111_101_101 /* 0755 */ }
            from(toolRoot.file("amethyst.desktop"))
            from(toolRoot.file("amethyst.png"))
            into(dir)
        }
        file("${dir}/.DirIcon").writeText("amethyst.png")
    }

    // linuxdeploy bundles deps + calls appimagetool internally
    commandLine(
        "${rootDir}/packaging/appimage/linuxdeploy-x86_64.AppImage",
        "--appdir", appDir.get().asFile.absolutePath,
        "--output", "appimage",
        "--desktop-file", "${appDir.get().asFile}/amethyst.desktop",
        "--icon-file", "${appDir.get().asFile}/amethyst.png",
    )
    environment("OUTPUT", outFile.get().asFile.absolutePath)
    environment("ARCH", "x86_64")
}

Supporting files (new, committed to repo under packaging/appimage/):

  • AppRun — shell launcher. Sets LD_LIBRARY_PATH including usr/lib/vlc so vlcj finds libvlc at runtime:
    #!/bin/bash
    HERE="$(dirname "$(readlink -f "$0")")"
    export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/vlc:${LD_LIBRARY_PATH}"
    export PATH="${HERE}/usr/bin:${PATH}"
    export APPDIR="${HERE}"
    exec "${HERE}/usr/bin/Amethyst" "$@"
    
  • amethyst.desktop — XDG Desktop Entry, includes MimeType=x-scheme-handler/nostr; for nostr: URI handling (future, non-breaking)
  • amethyst.png — 512×512 icon (scale from existing 100×100 icon.png using ImageMagick convert icon.png -resize 512x512 amethyst.png)

Build linuxdeploy fetch in CI (SHA-pinned, not continuous):

- name: Fetch linuxdeploy (pinned + SHA verified)
  run: |
    set -euo pipefail
    curl -fsSL --retry 3 \
      https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage \
      -o packaging/appimage/linuxdeploy-x86_64.AppImage
    echo "${LINUXDEPLOY_SHA256}  packaging/appimage/linuxdeploy-x86_64.AppImage" | sha256sum -c -
    chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage

Where LINUXDEPLOY_SHA256 is a known-good hash committed to the workflow.

Alternative: commit linuxdeploy-x86_64.AppImage (~10 MB, GPL) to the repo. Eliminates network fetch risk. Recommended.

  1. Portable tar.gz (Linux) + zip (Windows) — no Gradle tasks. Inline tar / zip in CI after createReleaseDistributable:
# Linux runner
- run: ./gradlew :desktopApp:createReleaseDistributable
- run: |
    cd desktopApp/build/compose/binaries/main-release/app
    tar czf "../../../../../amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/

# Windows runner
- run: ./gradlew :desktopApp:createReleaseDistributable
- run: |
    cd desktopApp/build/compose/binaries/main-release/app
    Compress-Archive -Path Amethyst -DestinationPath "../../../../../amethyst-desktop-${VER}-windows-x64.zip"
  shell: pwsh

Verification:

  • ./gradlew :desktopApp:packageReleaseRpm on Ubuntu with rpm installed → valid .rpm
  • ./gradlew :desktopApp:createReleaseAppImage on Ubuntu 22.04 → valid Amethyst-*-x86_64.AppImage (glibc 2.35 target; linuxdeploy bundles deps for compat; test on Fedora 40 + Alpine)
  • Inline tar on Linux → valid amethyst-desktop-*-linux-x64.tar.gz; extract + ./bin/Amethyst runs
  • Inline zip on Windows → valid amethyst-desktop-*-windows-x64.zip; extract + Amethyst.exe runs without installed JRE

Phase 3 — Release workflow rewrite [REFINED]

File: .github/workflows/create-release.yml (full rewrite of the desktop portions; keep Android portions intact)

Key changes from refinement:

  • Replace actions/create-release@v1 + actions/upload-release-asset@v1 with softprops/action-gh-release@v2, SHA-pinned
  • Expand desktop matrix to 4 runners (macos-13, macos-14, windows-latest, ubuntu-latest)
  • Each matrix job uploads directly to release via softprops/action-gh-release@v2 (no intermediate upload-artifact round-trip — saves 812 min + 1.5GB transfer per release)
  • No SHA256SUMS.txt — follows existing Amethyst convention (no checksum files on current releases)
  • No draft→publish flip — direct single-shot publish like existing create-release.yml:25
  • prerelease inferred from tag regex: -rc|-beta|-alpha → prerelease, otherwise stable
  • Tag-vs-catalog assertion: inline first step in each matrix job (no separate verify-version job — simplifies)
  • Remove Gradle cache from release workflow entirely (release builds are monthly; cache poisoning risk > warmup savings per performance + security review). PR build workflow (build.yml) keeps its cache.
  • Add per-asset size budget check: fail if any asset > 1 GB
  • Add timeout-minutes: 30 per matrix leg
  • Split ubuntu job into two matrix legs (deb+rpm, then AppImage+tar.gz) — halves critical-path time

Pseudo-code (abbreviated, SHA placeholders as <SHA>):

# .github/workflows/create-release.yml
name: Create Release
on:
  push:
    tags: ['v*']
permissions:
  contents: write

jobs:
  build-desktop:
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: macos-13,       tasks: "packageReleaseDmg",                                arch: x64,   family: macos   }
          - { os: macos-14,       tasks: "packageReleaseDmg",                                arch: arm64, family: macos   }
          - { os: windows-latest, tasks: "packageReleaseMsi createReleaseDistributable",    arch: x64,   family: windows }
          - { os: ubuntu-latest,  tasks: "packageReleaseDeb packageReleaseRpm",              arch: x64,   family: linux-installers }
          - { os: ubuntu-latest,  tasks: "createReleaseAppImage createReleaseDistributable", arch: x64,   family: linux-portable }
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30
    defaults: { run: { shell: bash } }
    steps:
      - uses: actions/checkout@<SHA>  # SHA-pinned; Dependabot-managed
      - uses: actions/setup-java@<SHA>
        with: { distribution: zulu, java-version: 21 }
      - name: Assert tag matches libs.versions.toml
        run: |
          TOML_VER=$(./gradlew -q printAppVersion)  # small Gradle task reads libs.versions.app
          TAG_VER="${GITHUB_REF_NAME#v}"
          [[ "$TOML_VER" == "$TAG_VER" ]] || { echo "::error::catalog=$TOML_VER tag=$TAG_VER"; exit 1; }
      - name: Install rpm tooling (linux only)
        if: startsWith(matrix.family, 'linux')
        run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
      - name: Fetch linuxdeploy (linux-portable only, SHA-pinned)
        if: matrix.family == 'linux-portable'
        run: |
          set -euo pipefail
          curl -fsSL --retry 3 \
            "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage" \
            -o packaging/appimage/linuxdeploy-x86_64.AppImage
          echo "${LINUXDEPLOY_SHA256}  packaging/appimage/linuxdeploy-x86_64.AppImage" | sha256sum -c -
          chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage
        env:
          LINUXDEPLOY_SHA256: <known-good-hash>
      - uses: nick-fields/retry@<SHA>
        with:
          max_attempts: 3
          timeout_minutes: 25
          command: ./gradlew :desktopApp:${{ matrix.tasks }} --no-daemon
      - name: Build inline portable archives
        if: matrix.family == 'windows' || matrix.family == 'linux-portable'
        run: |
          set -euo pipefail
          VER="${GITHUB_REF_NAME#v}"
          APP="desktopApp/build/compose/binaries/main-release/app"
          if [[ "${{ matrix.family }}" == "windows" ]]; then
            (cd "$APP" && powershell -c "Compress-Archive -Path Amethyst -DestinationPath ../../../../../amethyst-desktop-${VER}-windows-x64.zip")
          else
            (cd "$APP" && tar czf "../../../../../amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/)
          fi
      - name: Collect + rename assets
        id: collect
        run: |
          set -euo pipefail
          VER="${GITHUB_REF_NAME#v}"
          mkdir -p dist
          source scripts/asset-name.sh  # single source of truth (arch review A1)
          collect_assets "${{ matrix.family }}" "${{ matrix.arch }}" "$VER" dist/
      - name: Enforce asset size budget
        run: |
          for f in dist/*; do
            size=$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f")
            (( size <= 1073741824 )) || { echo "::error::$f is $(($size / 1048576)) MB (>1GB)"; exit 1; }
          done
      - name: Classify release
        id: classify
        run: |
          if [[ "${GITHUB_REF_NAME}" =~ -(rc|beta|alpha) ]]; then
            echo "is_prerelease=true" >> $GITHUB_OUTPUT
          else
            echo "is_prerelease=false" >> $GITHUB_OUTPUT
          fi
      - name: Upload to GH Release (direct)
        uses: softprops/action-gh-release@<SHA>
        with:
          files: dist/*
          prerelease: ${{ steps.classify.outputs.is_prerelease }}
          draft: false
          fail_on_unmatched_files: true
          generate_release_notes: true
          tag_name: ${{ github.ref_name }}  # upsert — reruns are idempotent

  deploy-android:
    # unchanged from current workflow (keep existing logic + assert-tag step)
    # ...

  publish-quartz:
    # unchanged
    # ...

Key security & performance deltas:

  • All uses: pinned to 40-char SHA (per security audit P0.1) — Dependabot-managed updates
  • linuxdeploy (not appimagetool with continuous tag) — versioned, SHA-verified (per security audit P0.2; performance audit too)
  • nick-fields/retry wraps Gradle — protects against transient VLC download / network flakes (performance audit §6)
  • Direct upload per matrix job — saves artifact round-trip (performance audit §4, §8)
  • Split ubuntu into 2 legs — halves Linux critical-path time; createReleaseDistributable runs once per leg but parallelizes (performance audit §2)
  • scripts/asset-name.sh — single source for asset naming, consumed by workflow + bump jobs + BUILDING.md (arch review A1)

Asset naming contract (committed in BUILDING.md):

amethyst-desktop-<version>-<family>-<arch>.<ext>

Where:

  • <version> = tag stripped of leading v (e.g. 1.06.3)
  • <family>macos, windows, linux
  • <arch>x64, arm64
  • <ext>dmg, msi, zip, deb, rpm, AppImage, tar.gz

Examples:

  • amethyst-desktop-1.06.3-macos-x64.dmg
  • amethyst-desktop-1.06.3-macos-arm64.dmg
  • amethyst-desktop-1.06.3-windows-x64.msi
  • amethyst-desktop-1.06.3-windows-x64.zip
  • amethyst-desktop-1.06.3-linux-x64.deb
  • amethyst-desktop-1.06.3-linux-x64.rpm
  • amethyst-desktop-1.06.3-linux-x64.AppImage
  • amethyst-desktop-1.06.3-linux-x64.tar.gz

Aggregate: SHA256SUMS.txt.

Phase 4 — Package-manager auto-bump workflows [REFINED: 2 workflows, not 4]

Two new workflows. Each gated on stable releases via release.released event (fires only for non-prereleases) AND explicit tag re-assertion at action boundary (defense-in-depth per security review).

Shared composite action .github/actions/assert-stable-release/action.yml:

name: Assert Stable Release
description: Re-validate tag format + prerelease flag before running bump actions
runs:
  using: composite
  steps:
    - shell: bash
      run: |
        set -euo pipefail
        TAG="${{ github.event.release.tag_name }}"
        # Defense-in-depth: reject prerelease suffix even if GH flag is false
        if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then
          echo "::error::Tag $TAG contains prerelease suffix"; exit 1
        fi
        if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
          echo "::error::Tag $TAG does not match vMAJOR.MINOR.PATCH"; exit 1
        fi
        if [[ "${{ github.event.release.draft }}" == "true" ]]; then
          echo "::error::Release is draft"; exit 1
        fi

.github/workflows/bump-homebrew.yml:

name: Bump Homebrew Cask
on:
  release:
    types: [released]  # only fires for non-prerelease
permissions: { contents: read }
concurrency:
  group: bump-homebrew-${{ github.event.release.tag_name }}
  cancel-in-progress: false
jobs:
  bump:
    if: github.event.release.prerelease == false
    runs-on: ubuntu-latest  # brew works on linux; saves macOS runner quota
    steps:
      - uses: actions/checkout@<SHA>  # SHA-pinned; Dependabot-managed
      - uses: ./.github/actions/assert-stable-release
      - uses: macauley/action-homebrew-bump-cask@<SHA>  # SHA-pinned
        with:
          token: ${{ secrets.HOMEBREW_TOKEN }}
          tap: homebrew/cask
          cask: amethyst-nostr
          tag: ${{ github.ref }}
      - name: Report failure
        if: failure()
        uses: actions/github-script@<SHA>
        with:
          script: |
            github.rest.issues.create({
              owner: context.repo.owner, repo: context.repo.repo,
              title: `[release-ops] bump-homebrew failed for ${context.payload.release.tag_name}`,
              body: `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
              labels: ['release-ops', 'bug']
            })

.github/workflows/bump-winget.yml:

name: Bump Winget Manifest
on:
  release:
    types: [released]
permissions: { contents: read }
concurrency:
  group: bump-winget-${{ github.event.release.tag_name }}
  cancel-in-progress: false
jobs:
  bump:
    if: github.event.release.prerelease == false
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@<SHA>
      - uses: ./.github/actions/assert-stable-release
      - uses: vedantmgoyal9/winget-releaser@<SHA>  # SHA-pinned
        with:
          identifier: VitorPamplona.Amethyst
          version: ${{ github.event.release.tag_name }}
          installers-regex: '^amethyst-desktop-.*windows-x64\.msi$'
          token: ${{ secrets.WINGET_TOKEN }}
      - name: Report failure
        if: failure()
        uses: actions/github-script@<SHA>
        with:
          script: |  # same issue-open pattern as above

Deferred to follow-up PR: bump-aur.yml, bump-scoop.yml — require maintainer to resolve AUR account owner + Scoop bucket strategy first (brainstorm Open Q1, Q2).

Phase 5 — Manifest files [REFINED: 3 files, not 11]

Only build-input files committed — package-manager manifests are generated by their respective bump actions from the live release:

Path Purpose
packaging/appimage/AppRun AppImage launcher shell script (sets LD_LIBRARY_PATH incl. bundled VLC dylibs)
packaging/appimage/amethyst.desktop AppImage XDG desktop entry
packaging/appimage/amethyst.png 512×512 AppImage icon (scale from existing icon.png)

Homebrew cask: lives in Homebrew/homebrew-cask after initial manual PR (bootstrap); subsequent releases rewrite it via action-homebrew-bump-cask which re-fetches asset URLs and computes SHA256 itself.

Winget manifests: generated by vedantmgoyal9/winget-releaser from prior version on each release.

AppImage tooling: use linuxdeploy instead of raw appimagetool for JVM+VLC library bundling (auto-scans usr/lib/ and handles rpath). linuxdeploy binary pinned to a released version (not continuous) and SHA256-verified when fetched in CI — or committed to packaging/appimage/linuxdeploy-x86_64.AppImage for supply-chain hardening (GPL, redistributable).

Phase 6 — Documentation [REFINED]

New file: BUILDING.md (repo root) — sections:

  1. Prerequisites — JDK 21 (Temurin/Zulu), Git, per-platform tools (rpm, fakeroot, linuxdeploy, WiX, Xcode CLI tools)
  2. Cloning + initial build — ./gradlew :desktopApp:run (dev), ./gradlew :desktopApp:packageDistributionForCurrentOS (package)
  3. Per-format build commands:
    • macOS (Intel or ARM): ./gradlew :desktopApp:packageReleaseDmg
    • Windows MSI: ./gradlew :desktopApp:packageReleaseMsi
    • Windows portable zip: ./gradlew :desktopApp:createReleaseDistributable && (cd build/compose/binaries/main-release/app && zip -r ../../../../../amethyst-desktop-windows-x64.zip Amethyst/)
    • Linux DEB: ./gradlew :desktopApp:packageReleaseDeb
    • Linux RPM: ./gradlew :desktopApp:packageReleaseRpm
    • Linux AppImage: ./gradlew :desktopApp:createReleaseAppImage
    • Linux tar.gz: ./gradlew :desktopApp:createReleaseDistributable && (cd build/compose/binaries/main-release/app && tar czf ../../../../../amethyst-desktop-linux-x64.tar.gz Amethyst/)
  4. Asset naming contract — single source in scripts/asset-name.sh (architectural review A1/A5)
  5. Release runbook (maintainer-facing): bump libs.versions.toml app, bump Android versionCode in amethyst/build.gradle, commit, tag, push; workflow auto-publishes
  6. Bootstrap runbook (one-time, maintainer-facing) — Homebrew + Winget only in this PR:
    • Create HOMEBREW_TOKEN (fine-grained PAT, Homebrew/homebrew-cask only, 90d expiry), manual first brew bump-cask-pr amethyst-nostr
    • Create WINGET_TOKEN (classic PAT, public_repo, 90d expiry), manual first submission via wingetcreate
    • 90-day rotation owner + calendar reminder (rotation runbook in BUILDING.md)
    • AUR + Scoop bootstrap: deferred to follow-up PR
  7. Troubleshooting: macOS Gatekeeper (xattr -cr, right-click Open), Windows SmartScreen ("More info → Run anyway"), Linux AppImage execute bit
  8. Uninstall + state paths per OS (macOS ~/Library/Application Support/Amethyst, Windows %APPDATA%\Amethyst, Linux ~/.config/amethyst)
  9. Incident response (per-channel recovery — security review P1.6): bad cask → fix-forward point release or revert PR; bad winget → removal PR to microsoft/winget-pkgs
  10. Fallback plans:
    • If macos-13 Intel runner retires: cross-build on macos-14 with explicit x64 JDK (runbook)
    • If Homebrew main-cask rejects unsigned (Sept 2026 enforcement): pivot to private tap vitorpamplona/homebrew-amethyst

Update: README.md — replace current ## Download and Install section:

## Download and Install

### Android
[existing badges]

### Desktop

| OS | CLI install | Direct download |
|---|---|---|
| macOS (Apple Silicon) | `brew install --cask amethyst-nostr` | [.dmg](https://github.com/vitorpamplona/amethyst/releases/latest) (arm64) |
| macOS (Intel) | `brew install --cask amethyst-nostr` | [.dmg](https://github.com/vitorpamplona/amethyst/releases/latest) (x64) |
| Windows 10/11 | `winget install VitorPamplona.Amethyst` | [.msi](https://...) · [.zip](https://...) portable |
| Debian/Ubuntu | — | [.deb](https://...) |
| Fedora/RHEL/openSUSE | — | [.rpm](https://...) |
| Any Linux | — | [AppImage](https://...) · [.tar.gz](https://...) |

_Coming soon (separate PR): Scoop (Windows), AUR (Arch Linux)._

**Build from source:** see [BUILDING.md](BUILDING.md).

**Troubleshooting installs:** see [BUILDING.md § Troubleshooting](BUILDING.md#troubleshooting).

Update Deploying section (README.md:250267) to reference BUILDING.md § Release runbook.

Detailed File Change List

Modify:

File Change
gradle/libs.versions.toml Add [versions] app = "1.06.3"
amethyst/build.gradle (L5758) versionName = generateVersionName(libs.versions.app.get())
desktopApp/build.gradle.kts Wire project.version = libs.versions.app.get(); drop packageVersion = "1.0.0" hardcode (inherit from project.version); add TargetFormat.Rpm; add linux DSL (rpmPackageVersion, menuGroup, etc.); register createAppImage, createPortableTarGz, createPortableZip tasks
.github/workflows/create-release.yml Rewrite desktop section per Phase 3; replace deprecated actions
.github/workflows/build.yml Expand PR-build matrix to build new formats (optional but recommended so PRs catch packaging regressions)
README.md Rewrite Download section; link BUILDING.md

Create:

File Purpose
BUILDING.md Build + release + bootstrap docs
.github/workflows/bump-homebrew.yml Homebrew cask auto-bump
.github/workflows/bump-winget.yml Winget manifest auto-submit
.github/workflows/bump-scoop.yml Scoop manifest auto-update
.github/workflows/bump-aur.yml AUR PKGBUILD auto-push
packaging/homebrew/amethyst-nostr.rb.tmpl Cask template
packaging/winget/*.yaml.tmpl Winget manifest templates (3 files)
packaging/scoop/amethyst.json.tmpl Scoop manifest template
packaging/aur/PKGBUILD.tmpl AUR PKGBUILD template
packaging/aur/amethyst.desktop Linux desktop entry (AUR)
packaging/appimage/AppRun AppImage launcher
packaging/appimage/amethyst.desktop AppImage desktop entry
packaging/appimage/amethyst.png AppImage icon (512×512)

Alternative Approaches Considered

Alternative Rejected because
Ad-hoc macOS codesign (codesign --sign -) Only prevents the "damaged" error on some macOS versions; Gatekeeper warning still shows. Brainstorm explicitly rejected (see brainstorm: Resolved Q2).
Full Apple Developer Program + notarization $99/yr budget not committed. Brainstorm deferred (see brainstorm: Deferred). Revisit when sponsor commits.
Flathub Moderate ongoing maintenance (manifest review cycle, sandboxing rules, Flatpak portals for filesystem access). Brainstorm deselected.
Snap Store FOSS-community distaste (proprietary Snap backend, forced auto-updates). Brainstorm deselected.
Mac App Store / MS Store Walled gardens conflict with FOSS alignment. Brainstorm deselected.
Chocolatey Redundant with Winget/Scoop for the target Windows audience (both CLI-first; Chocolatey adds virus-scan requirement + more manual review).
JReleaser (all-in-one packager) Heavy dependency that abstracts away control over jpackage + Compose Desktop plugin internals. Current Compose Desktop plugin does the heavy lifting; JReleaser would replace less than it adds. Revisit only if managing 4 separate bump workflows becomes painful.
Sparkle / in-app auto-update Requires signing to be trustworthy. Brainstorm deferred. Future work: in-app "check for update" banner polling GH Releases API.
Universal macOS DMG (via lipo) Compose Desktop's Skiko natives don't merge cleanly as universal binaries. Two smaller per-arch DMGs are simpler and smaller per-user.
Big-bang PR vs layered phases Brainstorm selected big-bang (maintainer preference — one review, one landing). Phases within the PR provide reviewer structure (see brainstorm: Sequencing).
Linux ARM64 / Windows ARM64 assets Niche demand; ubuntu-24.04-arm and windows-11-arm runners are public-repo-free but add matrix complexity. Park as future work; revisit on user demand.
F-Droid desktop (via flatpak) Out of brainstorm scope. Park.

System-Wide Impact

Interaction Graph

tag push (v1.06.3)
  │
  ▼
workflow: create-release.yml
  │
  ├─ verify-version            (asserts tag == libs.versions.app)
  ├─ build-desktop (4-way matrix)
  │    ├─ macos-13   → :desktopApp:packageReleaseDmg   → dist/*-macos-x64.dmg
  │    ├─ macos-14   → :desktopApp:packageReleaseDmg   → dist/*-macos-arm64.dmg
  │    ├─ windows    → :desktopApp:packageReleaseMsi + createPortableZip
  │    └─ ubuntu     → :desktopApp:packageReleaseDeb + packageReleaseRpm
  │                  + createAppImage + createPortableTarGz
  ├─ deploy-android           (existing logic; 12 APK/AAB assets)
  ├─ publish-quartz           (existing; Maven Central)
  └─ release                  (needs: all above)
        ├─ download all artifacts
        ├─ compute SHA256SUMS.txt
        ├─ classify prerelease from tag
        └─ softprops/action-gh-release@v2 publishes
  │
  ▼ release.published event (filtered: prerelease == false)
  │
  ├─ workflow: bump-homebrew.yml  → PR to Homebrew/homebrew-cask
  ├─ workflow: bump-winget.yml    → PR to microsoft/winget-pkgs
  ├─ workflow: bump-aur.yml       → push to aur.archlinux.org
  └─ workflow: bump-scoop.yml     → push to own bucket / Extras

Error & Failure Propagation

Failure Behavior Mitigation
verify-version fails (tag ≠ catalog) Entire workflow halts before any build Required fix before retag
One matrix job fails fail-fast: false — other jobs continue; release job blocked by needs: Fix the single failing job; rerun that job; release runs when all succeed
release job fails Artifacts remain uploaded; no GH Release created Rerun release job once fixed; artifacts retained 90 days
Bump-homebrew PR rejected upstream Bump action logs error; no user-facing impact Maintainer manually addresses; next release re-attempts
Bump-winget PR stuck in review Release claims "available via winget" prematurely Shadow-check via winget API and edit release notes (manual ops)
AUR SSH key failure Bump fails; AUR stays on old version Runbook in BUILDING.md for key rotation
VLC arm64 dylibs missing (plugin doesn't fetch) ARM DMG builds but crashes at runtime on video playback Risk R2 — verify pre-merge by running ./gradlew :desktopApp:packageReleaseDmg on macos-14 locally/CI and checking file output of dylibs in appResources/macos/vlc
VLC bundle exceeds 2GB GH asset limit Upload step fails Risk R9 — measure pre-merge; if close, set shouldIncludeAllVlcFiles = false and curate minimal plugin list
Draft release created but CI cancelled mid-upload Partial release with missing assets Use draft: false only after all uploads complete; retry release job is idempotent

State Lifecycle Risks

Step State persisted Cleanup Risk
GH Release draft creation Draft release on github.com Draft deleted by release job on retry Low — draft invisible to users
Matrix artifact upload GH Actions artifacts (90-day TTL) Auto-expire Low
Homebrew PR creation PR in Homebrew/homebrew-cask Maintainer can close Low
Winget PR creation PR in microsoft/winget-pkgs Can close Low
AUR push Irreversible — AUR repo updated Can push revert commit Medium — accidental push of broken v1.06.4 reaches Arch users within 1 yay -Syu cycle
User install from channel Files under /Applications (macOS), C:\Program Files\Amethyst (Windows), /opt/amethyst (Linux), user state dirs Uninstall per-channel Medium — state dirs shared across channels; downgrade via different channel could corrupt schema. Doc "single-channel" policy

API Surface Parity

  • Install surface: before this PR = GH Releases (single URL format). After = 4 channel install strings + direct-download matrix. Each channel exposes a different upgrade command (brew upgrade --cask, winget upgrade, scoop update, yay -Syu). Documented in README.
  • Version surface: before = one place (Android build.gradle), with desktop drifting independently. After = single source (libs.versions.toml); Android versionCode still manual.
  • Artifact surface: before = 3 desktop assets (one broken for Intel macOS users). After = 8 desktop assets + aggregate checksum file.

Integration Test Scenarios

Scenarios that unit/build tests won't catch — require manual or CI-integration validation:

  1. Intel macOS DMG actually runs on Intel hardware. file Amethyst.app/Contents/MacOS/Amethyst shows Mach-O 64-bit executable x86_64 — not universal, not arm64. Manual: fresh Intel Mac, right-click Open, app launches, signs in to Nostr relay.
  2. ARM macOS DMG runs on Apple Silicon without Rosetta. file shows Mach-O 64-bit executable arm64. Manual: fresh M-series Mac, VLC video note plays (validates VLC arm64 dylibs were bundled correctly — Risk R2).
  3. Homebrew cask install flow end-to-end. Fresh Mac VM: brew tap homebrew/cask && brew install --cask amethyst-nostr → app appears in /Applications → opens without right-click → uninstall leaves no state in ~/Library/Application Support/Amethyst unless user opts to preserve.
  4. Winget flow. Fresh Windows 11 VM: winget install VitorPamplona.Amethyst → app appears in Start Menu → launches → uninstall via Control Panel leaves no registry remnants under HKCU\Software\Amethyst.
  5. AppImage on unknown distro. Fresh Alpine/Void/NixOS container: chmod +x Amethyst-*.AppImage && ./Amethyst-*.AppImage works (validates AppImage self-containment + glibc 2.27 compat).
  6. Version contract. Push tag v1.06.4 where libs.versions.toml says app = "1.06.3"verify-version job fails fast; no assets built.
  7. Prerelease gating. Push v1.06.3-rc1 → release marked prerelease → bump-homebrew/winget/aur workflows do NOT trigger.
  8. Matrix partial failure. Simulate one runner failure → other 3 continue → release job blocked → retry of failed matrix job → release publishes successfully.

Acceptance Criteria

Functional Requirements

Phase 1 — Version source-of-truth:

  • gradle/libs.versions.toml contains [versions] app = "<semver>"
  • Root allprojects { version = libs.versions.app.get() } so subprojects inherit
  • ./gradlew :desktopApp:packageDistributionForCurrentOS produces asset with packageVersion matching catalog
  • ./gradlew :amethyst:assembleRelease produces APK with versionName matching catalog (plus branch suffix if applicable)
  • Inline tag-vs-catalog assertion fails when tag ≠ catalog (first step in each matrix job)

Phase 2 — Expanded packaging:

  • ./gradlew :desktopApp:packageReleaseRpm on Ubuntu with rpm installed → valid .rpm; rpm -qlp lists bundled VLC
  • ./gradlew :desktopApp:createReleaseAppImage on Ubuntu 22.04 → valid Amethyst-*-x86_64.AppImage; chmod +x + run launches app
  • Inline tar in CI produces valid amethyst-desktop-*-linux-x64.tar.gz; extract + ./bin/Amethyst runs
  • Inline Compress-Archive in CI produces valid .zip; extract + Amethyst.exe runs without installed JRE
  • AppImage runs on Alpine/NixOS container (glibc compat; linuxdeploy bundles libs)

Phase 3 — Release workflow:

  • actions/create-release@v1 and actions/upload-release-asset@v1 removed; softprops/action-gh-release@v2 (SHA-pinned) used
  • Matrix includes macos-13, macos-14, windows-latest, ubuntu-latest (× 2 for split deb/rpm + AppImage/tar.gz legs)
  • On tag push: 8 desktop assets + existing Android assets appear on GH Release (no SHA256SUMS.txt — follows existing convention)
  • Asset naming matches contract in scripts/asset-name.sh (single source of truth)
  • Release published directly (no draft→publish flip; matches existing workflow pattern)
  • prerelease: true iff tag matches v*-(rc|beta|alpha)*; stable tags publish as stable
  • Per-asset size ≤ 1 GB (enforced in workflow)
  • All third-party uses: SHA-pinned; Dependabot config added for .github/workflows/
  • linuxdeploy fetch is SHA-verified (or binary committed to packaging/appimage/)

Phase 4 — Auto-bump workflows (Homebrew + Winget only):

  • bump-homebrew.yml + bump-winget.yml present; gated on release.types: [released] + prerelease == false
  • Shared composite action .github/actions/assert-stable-release re-asserts tag format at action boundary
  • Failure auto-opens [release-ops] issue with run URL
  • concurrency: group per tag prevents re-fire races
  • Each workflow documented in BUILDING.md § Bootstrap runbook
  • AUR + Scoop bump workflows tracked for follow-up PR (not in this PR)

Phase 5 — Build-input files (3 files, not 11):

  • packaging/appimage/AppRun present (shellcheck clean)
  • packaging/appimage/amethyst.desktop present (desktop-file-validate clean)
  • packaging/appimage/amethyst.png present (≥ 512×512, valid PNG)
  • (Optional) packaging/appimage/linuxdeploy-x86_64.AppImage committed for supply-chain hardening

Phase 6 — Docs:

  • BUILDING.md at repo root; linked from README
  • README ## Download and Install includes per-OS desktop matrix; AUR/Scoop marked "Coming soon"
  • README references BUILDING.md for troubleshooting
  • Uninstall + state-dir paths documented per OS
  • Incident response section per channel (fix-forward + revert PR patterns)
  • macos-13 retirement fallback plan documented

Non-Functional Requirements

  • Release workflow end-to-end runtime ≤ 35 min cold / 25 min warm (revised per perf audit from +30% target)
  • No asset > 1 GB (enforced step in matrix)
  • VLC macOS dylib architecture verified on macos-13 (x86_64) and macos-14 (arm64) via file command in pre-merge dry-run

Quality Gates

  • All matrix OS builds pass on the PR branch
  • Existing Android release flow unchanged in behavior (diff Android asset list before/after)
  • spotlessApply clean on Kotlin changes
  • README renders correctly on GH
  • BUILDING.md verified by a second contributor on fresh macOS + Windows + Linux VMs
  • Pre-merge matrix dry-run via workflow_dispatch succeeds end-to-end

Success Metrics [REFINED]

Metric Baseline Target (90 days post-merge)
Intel Mac install works No (broken, macos-latest arm64 only) Yes
Package-manager channels (this PR) 0 2 (Homebrew, Winget)
GH Release asset count 3 desktop + 12 Android 8 desktop + 12 Android
Version drift incidents Currently 1.0.0 vs 1.06.3 0 (enforced by CI)

Dependencies & Prerequisites

Code dependencies

  • Compose Multiplatform 1.10.3 (already pinned) — supports all needed TargetFormat values
  • JDK 21 (already used)
  • ir.mahozad.vlc-setup 0.1.0 (already used) — confirmed fetches vlc-3.0.21-universal.dmg with arm64+x86_64 multi-arch dylibs; works on both macos-13 and macos-14 runners
  • linuxdeploy SHA-pinned (fetched per-CI-run OR committed to repo)
  • rpm + fakeroot (apt-installed on Ubuntu runner)

GH Actions dependencies (all SHA-pinned)

  • softprops/action-gh-release@<SHA> (v2.x)
  • actions/checkout@<SHA>, actions/setup-java@<SHA>
  • macauley/action-homebrew-bump-cask@<SHA> (v1.x)
  • vedantmgoyal9/winget-releaser@<SHA> (v2.x)
  • nick-fields/retry@<SHA> (for transient VLC download retries)
  • actions/github-script@<SHA> (failure issue auto-open)
  • Dependabot config for .github/workflows/ to auto-PR SHA updates

Secrets to provision (one-time bootstrap by maintainer)

  • HOMEBREW_TOKEN — fine-grained PAT (scoped to Homebrew/homebrew-cask only, Contents: write + Pull requests: write), 90d expiry
  • WINGET_TOKEN — classic PAT with public_repo (winget-releaser requires classic), 90d expiry, dedicated bot account preferred

External prerequisites (bootstrap runbook in BUILDING.md)

  • Homebrew cask amethyst-nostr merged to Homebrew/homebrew-cask via manual brew bump-cask-pr once (then auto-bumped)
  • Winget VitorPamplona.Amethyst submitted once via wingetcreate (then auto-bumped by winget-releaser)
  • LINUXDEPLOY_SHA256 hash constant committed to workflow (update when linuxdeploy version bumps)

Risk Analysis & Mitigation [REFINED]

Structured from SpecFlow + brainstorm + security/perf/arch deepen reviews:

# Risk Likelihood Impact Mitigation
R1 Homebrew-cask unsigned-app enforcement Sept 1, 2026 Confirmed High — kills main macOS CLI path Time-boxed: Budget $99/yr Apple Developer Program before Sept 2026 OR pivot to private tap vitorpamplona/homebrew-amethyst (private tap does NOT bypass Gatekeeper, but sidesteps Homebrew policy). Documented in BUILDING.md Fallbacks.
R2 VLC arm64 macOS dylibs missing RESOLVED False alarm. Plugin fetches vlc-3.0.21-universal.dmg (85MB, 2-arch). Bundled libvlc.dylib/libvlccore.dylib in repo verified as Mach-O universal binary with 2 architectures: [x86_64] [arm64]. vlcj 4.8.3 auto-selects matching arch slice at runtime. Source: VlcDownloadTask.kt in mahozad/vlc-setup.
R3 macos-13 (Intel) runner retirement by GitHub High eventually Med — Intel DMG builds break Track GH runner deprecation; fallback documented in BUILDING.md (cross-arch build on macos-14 with x64 JDK).
R4 Tag must be pushed to prod to test full fan-out High Med — maintainer anxiety Include workflow_dispatch with dry_run: true input that builds + creates a test-only release; skips bump workflows.
R5 Asset naming change breaks auto-bump manifests Low High Single source scripts/asset-name.sh consumed by workflow + bump jobs + BUILDING.md (arch review A1)
R6 Supply chain — unsigned artifacts + no signed checksums Accepted Med Matches existing Amethyst convention (Android is signed via APK signature; desktop releases have no parallel today). Sigstore/cosign revisit is future work.
R7 AUR account single-point-of-failure Deferred to follow-up PR
R8 Winget moderator review latency High Low README flags Winget as "Coming soon" until manifest is merged; 2472h expected lag
R9 VLC bundle pushes AppImage over GH 1GB/asset budget Low High — release fails Pre-flight benchmark: local AppImage build before merge; workflow enforces ≤1GB per asset and fails early
R10 Windows upgradeUuid hardcoded — change breaks MSI upgrades Low Med Document "NEVER change" in BUILDING.md § Release runbook
R11 GH Actions secret rotation — no owner Med Med — bumps stop working silently 90-day rotation runbook in BUILDING.md; calendar reminder; each bump workflow auto-opens [release-ops] issue on failure
R12 Prerelease gating bug pushes RC to stable channels Med High Shared composite action assert-stable-release re-asserts tag format + draft flag at action boundary (security P0.4)
R13 Cross-channel installs share state dir; downgrade corrupts Low Med Document single-channel policy in BUILDING.md; startup version check is future work
R14 Compromise of third-party GH Action (tj-actions Mar 2025 precedent) Med High All third-party actions SHA-pinned + Dependabot-managed (security P0.1)
R15 appimagetool/linuxdeploy fetched from continuous tag = unpinned Med High Pin to released version + SHA256 verify OR commit binary to repo (security P0.2)
R16 Matrix job partial success leaves release in inconsistent state Low Low Each matrix job uploads direct (idempotent upsert via tag_name:); fail_on_unmatched_files: true
R17 Cache poisoning across PR and release workflows Low High Remove Gradle cache from release workflow entirely (cold cache cost ~4min << poisoning risk); keep cache only in build.yml PR workflow (security P1.3)

Resource Requirements

  • Engineer time: 1 engineer (me/Claude) — phased work within a single PR; time estimate omitted per user instruction
  • Maintainer time (@vitorpamplona):
    • One-time bootstrap: ~2h (AUR account, Homebrew manual PR, Winget manual submission, PATs, Scoop decision)
    • Per-release (post-bootstrap): ~5 min (bump libs.versions.toml, bump Android versionCode, tag, push — then monitor)
  • Infra: free (all GH-hosted runners on public repo free tier); no paid services
  • External review: second contributor on macOS + Windows + Linux VMs to verify BUILDING.md freshly

Future Considerations [REFINED]

Out of scope for this PR — tracked as separate future work:

  1. Code signing — Apple Developer Program ($99/yr) + macOS notarization + Windows Authenticode. Time-boxed to Sept 1, 2026 per Homebrew Gatekeeper enforcement (Risk R1).
  2. AUR channel (amethyst-desktop-bin) — separate follow-up PR once account ownership decided
  3. Scoop channel — separate follow-up PR once bucket strategy decided
  4. In-app "check for update" banner — poll GH Releases API; modest scope
  5. Sparkle / Squirrel auto-update — requires signing
  6. Flathub — sandboxed Linux app center
  7. Mac App Store / MS Store — walled gardens
  8. Chocolatey — redundant with Winget/Scoop
  9. Linux ARM64 + Windows ARM64 assetsubuntu-24.04-arm / windows-11-arm runners available; add on demand
  10. .desktop MIME handler for nostr: URIs — cheap Linux-integration add
  11. Sigstore/cosign signing — supply-chain hardening (Risk R6)
  12. SLSA build provenance attestationactions/attest-build-provenance (security P2.1)
  13. SBOM generation — CycloneDX/SPDX per release (security P2.3)
  14. Weekly channel integrity cron — detect package-mgr manifest drift (security P2.4)
  15. ScoopInstaller/Extras PR (if starting with own bucket) — discoverability boost
  16. Localized install matrix via Crowdin

Research Insights (from deepen-plan)

This plan was deepened with 10 parallel agents. Key findings that shaped the refinements above:

Architecture (architecture-strategist)

  • A1: Asset naming contract is duplicated in 5+ places — extracted to scripts/asset-name.sh as single source of truth.
  • A4: Prose said "draft → publish"; pseudo-code did single-shot. Aligned to single-shot (matches existing create-release.yml:25).
  • A5: packaging/ directory mixes build-inputs and publish-templates — refined to build-inputs only (templates generated by bump actions).

Security (security-sentinel) — 4 P0 block-merge items

  • P0.1: All third-party actions SHA-pinned (tj-actions March 2025 incident precedent).
  • P0.2: appimagetool / linuxdeploy fetched with SHA256 verification (or committed to repo).
  • P0.3: Checksums debate — followed existing Amethyst convention (no checksums file). Sigstore signing deferred as future work.
  • P0.4: Bump workflows re-assert tag format + draft flag at action boundary via shared composite action.
  • P1.3: Removed Gradle cache from release workflow (cache poisoning risk > warmup savings for monthly releases).

Performance (performance-oracle)

  • §4, §8: Direct upload per matrix job saves 812 min + 1.5GB double-transfer vs artifact round-trip.
  • §2: Split ubuntu job into 2 matrix legs (deb+rpm, AppImage+tar.gz) — halves Linux critical-path time.
  • §5: appimagetool "continuous" tag unpinned; use released version SHA-pinned.
  • SLO: Revised from "+30% of current" to explicit "≤35 min cold / ≤25 min warm" based on asset-size modeling.

Simplicity (code-simplicity-reviewer)

  • Dropped 8 of 11 template files (Homebrew cask + Winget manifests generated by bump actions).
  • Dropped Gradle tasks for tar.gz/zip (inline tar/Compress-Archive in CI).
  • Dropped separate verify-version job (inline assertion in each matrix job).
  • Deferred AUR + Scoop to follow-up PR (unresolved open questions were dragging scope).

Deployment verification (deployment-verification-agent)

  • Go/No-Go checklist with VLC arm64 dylib check, asset size enforcement, pre-merge dry-run.
  • Rollback procedures per channel (fix-forward point release or revert PR).
  • Alert channel chosen: GH Issue auto-open on bump failure (zero infra).

Pattern consistency (pattern-recognition-specialist)

  • Renamed createAppImagecreateReleaseAppImage (matches createReleaseDistributable dependency).
  • Renamed HOMEBREW_PAT / WINGET_PATHOMEBREW_TOKEN / WINGET_TOKEN (matches existing SONATYPE_PASSWORD pattern).
  • Asset naming extracted to scripts/asset-name.sh single source.
  • Bump workflow assert-stable-release composite action deduplicates prerelease re-check across workflows.

External research

  • AppImage + Compose Desktop: use linuxdeploy (not raw appimagetool) for JVM+VLC library bundling. Build on Ubuntu 22.04+ (glibc 2.35); linuxdeploy handles compat.
  • Homebrew 2026 reality: unsigned casks will be disabled Sept 1, 2026. Private tap does NOT bypass Gatekeeper — macOS-OS-level. Signing budget decision time-boxed.
  • Gradle catalog pattern: libs.versions.toml [versions] app consumed via root allprojects { version = libs.versions.app.get() } so subprojects inherit project.version. Avoids multi-module resolution drift.
  • VLC arm64 macOS: resolved — false alarm. ir.mahozad.vlc-setup:0.1.0 fetches vlc-3.0.21-universal.dmg (85MB, 2-arch) per VlcDownloadTask.kt source. Bundled libvlc.dylib/libvlccore.dylib in this repo verified as Mach-O universal binary with 2 architectures: [x86_64] [arm64]. vlcj 4.8.3 auto-selects matching arch slice at runtime. Current ARM DMG video playback is functional. Only issue was Intel Mac (addressed by matrix expansion).

Documentation Plan

New documentation:

  • BUILDING.md — authoritative source for build + release + bootstrap
  • README desktop install matrix

Updated documentation:

  • README Deploying section references BUILDING.md § Release runbook
  • CHANGELOG entry summarizing the distribution expansion

Not needed:

  • No API docs impact
  • No user-facing feature docs (install story, not feature)

Sources & References

Origin

  • Brainstorm document: docs/brainstorms/2026-04-16-desktop-multiplatform-distribution-brainstorm.md
  • Key decisions carried forward from brainstorm:
    • Ship unsigned + document workarounds (brainstorm: Resolved Q2)
    • Lockstep desktop version with Android (brainstorm: Resolved Q5)
    • Package-mgr push cadence: stable tags only (brainstorm: Resolved Q6)
    • VLC bundled everywhere (brainstorm: Resolved Q7)
    • AppImage via appimagetool wrapping createDistributable (brainstorm: Resolved Q3)
    • Homebrew cask name amethyst-nostr (brainstorm: Resolved Q1)
    • Winget PackageIdentifier = VitorPamplona.Amethyst (brainstorm: Resolved Q4)
    • Sequencing: big-bang PR (brainstorm: Key Decisions)
    • Out of scope: signing, Flathub, Snap, walled gardens, auto-update (brainstorm: Deferred)

Internal References

  • Current Compose Desktop config: desktopApp/build.gradle.kts:1124
  • Hardcoded version drift: desktopApp/build.gradle.kts:90 (packageVersion = "1.0.0")
  • Current release workflow: .github/workflows/create-release.yml:1306
  • Current build workflow: .github/workflows/build.yml:1207
  • Android version logic: amethyst/build.gradle:1036, 5758
  • Gradle version catalog: gradle/libs.versions.toml:1195
  • VLC plugin config: desktopApp/build.gradle.kts:112119
  • Current README install section: README.md:2236
  • Current README deploy section: README.md:250267

External References

  • None open. No prior PRs/issues in Amethyst repo on packaging/signing/Flathub/Homebrew/AppImage.

Open Questions (for @vitorpamplona resolution) [REFINED]

Split by resolution timing:

Must resolve before merge

  1. VLC arm64 macOS verificationRESOLVED (R2 false alarm; plugin fetches universal DMG; bundled dylibs already arm64+x86_64 multi-arch).
  2. debMaintainer email — what contact email should appear in .deb metadata?
  3. AppImage icon scaling — OK to scale existing 100×100 icon.png to 512×512 via ImageMagick, or commission a proper 512×512?
  4. Dry-run workflow dispatch — include workflow_dispatch + dry_run: true input in this PR? Strongly recommended by deployment verification agent.

Can resolve during implementation

  1. Secret rotation owner — who owns 90-day rotation of HOMEBREW_TOKEN, WINGET_TOKEN? (Calendar reminder, runbook owner)
  2. Apple Developer Program signing budget — time-boxed to Sept 2026 Gatekeeper enforcement. Decision: (a) commit $99/yr now and add signing/notarization in a follow-up, (b) pivot to private tap before Sept 2026, (c) abandon Homebrew cask path. (Risk R1)
  3. CHANGELOG entry wording — auto-generated from commits via generate_release_notes: true, or hand-written summary?

Deferred to follow-up PR (not in scope for this PR)

  1. AUR account ownership — blocks AUR bootstrap entirely (brainstorm: Open Q1)
  2. Scoop bucket strategy — own bucket vs Extras (brainstorm: Open Q2)