diff --git a/docs/plans/2026-04-16-feat-desktop-multiplatform-distribution-plan.md b/docs/plans/2026-04-16-feat-desktop-multiplatform-distribution-plan.md deleted file mode 100644 index 538cdec2d4..0000000000 --- a/docs/plans/2026-04-16-feat-desktop-multiplatform-distribution-plan.md +++ /dev/null @@ -1,1024 +0,0 @@ ---- -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)