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

1025 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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: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.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 <contact@amethyst>" // 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 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>`):
```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> # 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`:
```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> # 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`:**
```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@<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:
```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: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 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 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 `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: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
- **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)