--- title: Desktop Multi-Platform Distribution type: feat status: active date: 2026-04-16 origin: docs/brainstorms/2026-04-16-desktop-multiplatform-distribution-brainstorm.md deepened: 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**: `createAppImage` → `createReleaseAppImage` (aligns with Compose's `createReleaseDistributable`). Secret names `HOMEBREW_PAT`/`WINGET_PAT` → `HOMEBREW_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:264–305` | | Install docs | README §Download lists Android-only links (Zap Store, Obtainium, Play, GH). No desktop install section | `README.md:22–36` | | Build docs | No `BUILDING.md`, `CONTRIBUTING.md`, or `RELEASING.md` | repo root | | Version sync | `versionCode` + `versionName` hardcoded in `amethyst/build.gradle:57–58`; desktop hardcoded separately | `amethyst/build.gradle:57–58` | | 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 8–12 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.yml` — `action-homebrew-bump-cask` on `ubuntu-latest` (brew works on Linux; saves macOS runner quota) - `.github/workflows/bump-winget.yml` — `vedantmgoyal9/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`): ```kotlin // 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 " // open question: email rpmLicenseType = "MIT" } // ... existing macOS + windows blocks unchanged } } } ``` `amethyst/build.gradle` wiring: ```groovy // 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: ```kotlin 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: ```bash #!/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`): ```yaml - 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. 3. **Portable tar.gz (Linux) + zip (Windows)** — no Gradle tasks. Inline `tar` / `zip` in CI after `createReleaseDistributable`: ```yaml # 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 8–12 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 ``): ```yaml # .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-pinned; Dependabot-managed - uses: actions/setup-java@ 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: - uses: nick-fields/retry@ 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@ 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---. ``` Where: - `` = tag stripped of leading `v` (e.g. `1.06.3`) - `` ∈ `macos`, `windows`, `linux` - `` ∈ `x64`, `arm64` - `` ∈ `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`: ```yaml 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`:** ```yaml 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-pinned; Dependabot-managed - uses: ./.github/actions/assert-stable-release - uses: macauley/action-homebrew-bump-cask@ # 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@ 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`:** ```yaml 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@ - uses: ./.github/actions/assert-stable-release - uses: vedantmgoyal9/winget-releaser@ # 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@ 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: ```markdown ## 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:250–267`) 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` (L57–58) | `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 = ""` - [ ] 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@` (v2.x) - `actions/checkout@`, `actions/setup-java@` - `macauley/action-homebrew-bump-cask@` (v1.x) - `vedantmgoyal9/winget-releaser@` (v2.x) - `nick-fields/retry@` (for transient VLC download retries) - `actions/github-script@` (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; 24–72h 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 assets** — `ubuntu-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 attestation** — `actions/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 8–12 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 `createAppImage` → `createReleaseAppImage` (matches `createReleaseDistributable` dependency). - Renamed `HOMEBREW_PAT` / `WINGET_PAT` → `HOMEBREW_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`](../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:1–124` - Hardcoded version drift: `desktopApp/build.gradle.kts:90` (`packageVersion = "1.0.0"`) - Current release workflow: `.github/workflows/create-release.yml:1–306` - Current build workflow: `.github/workflows/build.yml:1–207` - Android version logic: `amethyst/build.gradle:10–36, 57–58` - Gradle version catalog: `gradle/libs.versions.toml:1–195` - VLC plugin config: `desktopApp/build.gradle.kts:112–119` - Current README install section: `README.md:22–36` - Current README deploy section: `README.md:250–267` ### External References - **Compose Multiplatform 1.10.x packaging DSL**: https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html - **TargetFormat enum (v1.10.3)**: https://github.com/JetBrains/compose-multiplatform/blob/v1.10.3/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt - **AppImage `TargetFormat` broken (CMP-7101)**: https://youtrack.jetbrains.com/issue/CMP-7101 - **jpackage spec (JDK 21)**: https://docs.oracle.com/en/java/javase/21/docs/specs/man/jpackage.html - **JDK-8266179** (no cross-arch): https://bugs.openjdk.org/browse/JDK-8266179 - **softprops/action-gh-release**: https://github.com/softprops/action-gh-release - **GitHub Actions runner reference**: https://docs.github.com/en/actions/reference/runners/github-hosted-runners - **Homebrew Acceptable Casks**: https://docs.brew.sh/Acceptable-Casks - **Homebrew 5.x `--no-quarantine` deprecation**: https://github.com/Homebrew/brew/issues/20755 - **`macauley/action-homebrew-bump-cask`**: https://github.com/macauley/action-homebrew-bump-cask - **Winget manifest schema**: https://learn.microsoft.com/en-us/windows/package-manager/package/manifest - **`vedantmgoyal9/winget-releaser`**: https://github.com/vedantmgoyal9/winget-releaser - **Scoop App Manifest Autoupdate**: https://github.com/ScoopInstaller/Scoop/wiki/App-Manifest-Autoupdate - **ArchWiki PKGBUILD**: https://wiki.archlinux.org/title/PKGBUILD - **`KSXGitHub/github-actions-deploy-aur`**: https://github.com/KSXGitHub/github-actions-deploy-aur - **AppImage Bundling Java apps**: https://github.com/AppImage/AppImageKit/wiki/Bundling-Java-apps - **Gradle Version Catalogs**: https://docs.gradle.org/current/userguide/version_catalogs.html - **Gossip (nostr) install docs** — precedent: https://github.com/mikedilger/gossip/blob/master/docs/INSTALLATION.md ### Related Work - 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 verification~~ — **RESOLVED** (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 5. **Secret rotation owner** — who owns 90-day rotation of `HOMEBREW_TOKEN`, `WINGET_TOKEN`? (Calendar reminder, runbook owner) 6. **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) 7. **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) 8. **AUR account ownership** — blocks AUR bootstrap entirely (brainstorm: Open Q1) 9. **Scoop bucket strategy** — own bucket vs Extras (brainstorm: Open Q2)