diff --git a/amethyst/build.gradle b/amethyst/build.gradle index f52cb777c5..c83ae17f8c 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -55,7 +55,7 @@ android { minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() versionCode = 442 - versionName = generateVersionName("1.08.0") + versionName = generateVersionName(libs.versions.app.get()) buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/build.gradle b/build.gradle index f065a2b449..437eb14d50 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,12 @@ plugins { alias(libs.plugins.serialization) } +// Shared app version for all subprojects — read from gradle/libs.versions.toml. +// Android versionCode stays local in amethyst/build.gradle (must be monotonic int). +// Desktop packageVersion inherits via project.version in desktopApp/build.gradle.kts. allprojects { + version = libs.versions.app.get() + configurations.configureEach { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index f413928a3e..742051e2ea 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,4 +1,6 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.io.File +import java.nio.file.Files plugins { alias(libs.plugins.jetbrainsKotlinJvm) @@ -7,6 +9,11 @@ plugins { id("ir.mahozad.vlc-setup") version "0.1.0" } +// RPM rejects dashes in version strings — strip prerelease suffix for Linux RPM only. +// Other formats accept full semver (DEB uses ~rc1, DMG/MSI accept bare versions). +val appVersion: String = project.version.toString() +val appVersionRpm: String = appVersion.substringBefore("-") + sourceSets { main { kotlin.srcDir("src/jvmMain/kotlin") @@ -88,11 +95,11 @@ compose.desktop { nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) modules("java.management") // Required by kmp-tor TorRuntime packageName = "Amethyst" - packageVersion = "1.0.0" + packageVersion = appVersion description = "Nostr client for desktop" vendor = "Amethyst Contributors" @@ -109,6 +116,12 @@ compose.desktop { linux { iconFile.set(project.file("src/jvmMain/resources/icon.png")) + menuGroup = "Network" + appCategory = "Network" + debMaintainer = "vitor@vitorpamplona.com" + rpmLicenseType = "MIT" + // RPM version field rejects dashes; strip prerelease suffix for RPM builds. + rpmPackageVersion = appVersionRpm } } } @@ -126,3 +139,68 @@ vlcSetup { tasks.named("spotlessKotlin") { mustRunAfter("vlcSetup") } + +// --- AppImage packaging (Linux) --- +// +// Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101). +// Instead: wrap `createReleaseDistributable` output with `linuxdeploy` (which +// auto-bundles libraries, handles rpath, and calls appimagetool internally). +// +// Build inputs live in packaging/appimage/: +// - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC) +// - amethyst.desktop XDG desktop entry +// - amethyst.png 512x512 icon +// +// linuxdeploy binary is fetched by CI (SHA-verified) into packaging/appimage/ +// as linuxdeploy-x86_64.AppImage. BUILDING.md documents local-dev fetch. +val createReleaseAppImage by tasks.registering(Exec::class) { + group = "compose desktop" + description = "Bundle createReleaseDistributable output into a Linux AppImage via linuxdeploy." + 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-$appVersion-x86_64.AppImage") + val toolRoot = layout.projectDirectory.dir("../packaging/appimage") + val linuxdeployTool = toolRoot.file("linuxdeploy-x86_64.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" } + filePermissions { unix("0755") } + } + from(toolRoot.file("amethyst.desktop")) + from(toolRoot.file("amethyst.png")) + into(dir) + } + // DirIcon is used by desktop integrations (file managers, AppImageLauncher) + val dirIcon = File(dir, ".DirIcon") + if (dirIcon.exists()) dirIcon.delete() + Files.createSymbolicLink(dirIcon.toPath(), File("amethyst.png").toPath()) + + if (!linuxdeployTool.asFile.canExecute()) { + linuxdeployTool.asFile.setExecutable(true) + } + } + + commandLine( + linuxdeployTool.asFile.absolutePath, + "--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") + // Suppress linuxdeploy's verbose library-scanner output; keep errors. + environment("LINUXDEPLOY_OUTPUT_VERSION", appVersion) +} 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 new file mode 100644 index 0000000000..538cdec2d4 --- /dev/null +++ b/docs/plans/2026-04-16-feat-desktop-multiplatform-distribution-plan.md @@ -0,0 +1,1024 @@ +--- +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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d5f69f920..149a49a310 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,8 @@ [versions] +# Amethyst app version — single source of truth consumed by both Android (amethyst/) +# and Desktop (desktopApp/). Android versionCode is bumped independently in +# amethyst/build.gradle because it must be a monotonic integer. +app = "1.08.0" accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" composeMultiplatform = "1.10.3" diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun new file mode 100755 index 0000000000..9b2fb923d1 --- /dev/null +++ b/packaging/appimage/AppRun @@ -0,0 +1,9 @@ +#!/bin/bash +# AppImage launcher for Amethyst Desktop. +# Sets LD_LIBRARY_PATH to find bundled VLC natives (vlcj dlopens libvlc.so at runtime). +set -e +HERE="$(dirname "$(readlink -f "${0}")")" +export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/vlc:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" +export PATH="${HERE}/usr/bin:${PATH}" +export APPDIR="${HERE}" +exec "${HERE}/usr/bin/Amethyst" "$@" diff --git a/packaging/appimage/amethyst.desktop b/packaging/appimage/amethyst.desktop new file mode 100644 index 0000000000..5c59c74de1 --- /dev/null +++ b/packaging/appimage/amethyst.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Amethyst +GenericName=Nostr Client +Comment=Nostr client for desktop +Exec=Amethyst %u +Icon=amethyst +Categories=Network;InstantMessaging; +Terminal=false +StartupNotify=true +StartupWMClass=Amethyst +Keywords=nostr;client;social;chat; diff --git a/packaging/appimage/amethyst.png b/packaging/appimage/amethyst.png new file mode 100644 index 0000000000..030e45adee Binary files /dev/null and b/packaging/appimage/amethyst.png differ diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh new file mode 100755 index 0000000000..9010e1d02f --- /dev/null +++ b/scripts/asset-name.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Single source of truth for Amethyst Desktop release asset naming. +# +# Contract: amethyst-desktop---. +# = tag stripped of leading 'v' (e.g. "1.08.0") +# = macos | windows | linux +# = x64 | arm64 +# = dmg | msi | zip | deb | rpm | AppImage | tar.gz +# +# Consumed by: .github/workflows/create-release.yml, .github/workflows/bump-*.yml, +# BUILDING.md, local release runbooks. +# +# Usage: +# source scripts/asset-name.sh +# collect_assets +# +# Expected examples: +# amethyst-desktop-1.08.0-macos-x64.dmg +# amethyst-desktop-1.08.0-macos-arm64.dmg +# amethyst-desktop-1.08.0-windows-x64.msi +# amethyst-desktop-1.08.0-windows-x64.zip +# amethyst-desktop-1.08.0-linux-x64.deb +# amethyst-desktop-1.08.0-linux-x64.rpm +# amethyst-desktop-1.08.0-linux-x64.AppImage +# amethyst-desktop-1.08.0-linux-x64.tar.gz + +set -euo pipefail + +# Print the canonical asset filename for a given family/arch/version/extension. +# Usage: asset_name +asset_name() { + local family="$1" arch="$2" version="$3" ext="$4" + printf 'amethyst-desktop-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" +} + +# Copy + rename build outputs into using the canonical naming scheme. +# Usage: collect_assets +# Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives). +collect_assets() { + local family="$1" arch="$2" version="$3" dest="$4" + mkdir -p "$dest" + shopt -s nullglob + + # Compose Desktop jpackage outputs (main-release//*.ext) + local src ext base dst + for src in \ + desktopApp/build/compose/binaries/main-release/dmg/*.dmg \ + desktopApp/build/compose/binaries/main-release/msi/*.msi \ + desktopApp/build/compose/binaries/main-release/deb/*.deb \ + desktopApp/build/compose/binaries/main-release/rpm/*.rpm \ + desktopApp/build/appimage/*.AppImage \ + desktopApp/build/portable/*.tar.gz \ + desktopApp/build/portable/*.zip \ + ; do + [ -f "$src" ] || continue + base="$(basename "$src")" + case "$base" in + *.tar.gz) ext="tar.gz" ;; + *) ext="${base##*.}" ;; + esac + dst="$dest/$(asset_name "$family" "$arch" "$version" "$ext")" + cp "$src" "$dst" + echo "Collected: $dst" + done + shopt -u nullglob +}