From da1037423c720067a3adbc3f4adfc1fd403d970f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 16 Apr 2026 14:35:01 +0300 Subject: [PATCH 1/4] feat(desktop): version source-of-truth + RPM + AppImage packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1+2 of multi-platform distribution plan: - gradle/libs.versions.toml: add `app = "1.08.0"` as single source of truth - Root build.gradle: allprojects { version = libs.versions.app.get() } - amethyst/build.gradle: versionName from catalog (versionCode stays local) - desktopApp/build.gradle.kts: drop hardcoded "1.0.0"; inherit project.version; add TargetFormat.Rpm; add linux DSL (menuGroup, appCategory, debMaintainer, rpmLicenseType, rpmPackageVersion with dashes stripped) - desktopApp/build.gradle.kts: new createReleaseAppImage task wrapping createReleaseDistributable with linuxdeploy (TargetFormat.AppImage is broken in Compose 1.10.x — CMP-7101) - packaging/appimage/: AppRun launcher (sets LD_LIBRARY_PATH for bundled VLC), amethyst.desktop XDG entry, 512x512 icon extracted from icon.icns - scripts/asset-name.sh: single source for release asset naming contract --- amethyst/build.gradle | 2 +- build.gradle | 5 + desktopApp/build.gradle.kts | 82 +- ...desktop-multiplatform-distribution-plan.md | 1024 +++++++++++++++++ gradle/libs.versions.toml | 4 + packaging/appimage/AppRun | 9 + packaging/appimage/amethyst.desktop | 12 + packaging/appimage/amethyst.png | Bin 0 -> 73540 bytes scripts/asset-name.sh | 66 ++ 9 files changed, 1201 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-04-16-feat-desktop-multiplatform-distribution-plan.md create mode 100755 packaging/appimage/AppRun create mode 100644 packaging/appimage/amethyst.desktop create mode 100644 packaging/appimage/amethyst.png create mode 100755 scripts/asset-name.sh diff --git a/amethyst/build.gradle b/amethyst/build.gradle index f52cb777c..c83ae17f8 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 f065a2b44..437eb14d5 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 f413928a3..742051e2e 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 000000000..538cdec2d --- /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 0d5f69f92..149a49a31 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 000000000..9b2fb923d --- /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 000000000..5c59c74de --- /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 0000000000000000000000000000000000000000..030e45adee5dccef394b03178c615c866fb8ce8e GIT binary patch literal 73540 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 literal 0 HcmV?d00001 diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh new file mode 100755 index 000000000..9010e1d02 --- /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 +} From c42866160123a83ce33b14a58d37bd6dbec18eac Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 16 Apr 2026 14:54:51 +0300 Subject: [PATCH 2/4] feat(release): expand desktop distribution to 8 assets + 2 package managers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 3, 4, 5, 6 of the multi-platform distribution plan. ## create-release.yml rewrite - Replace deprecated actions/create-release@v1 + upload-release-asset@v1 with softprops/action-gh-release@v2 (SHA-pinned) - Expand build-desktop matrix: macos-13 (Intel), macos-14 (ARM), windows-latest, ubuntu-latest × 2 legs (deb+rpm, AppImage+tar.gz) - Each matrix job uploads directly to release (no artifact round-trip — saves ~10 min + 1.5GB transfer per run) - Inline portable archives: Windows .zip via 7z, Linux .tar.gz via tar - linuxdeploy SHA-verified fetch for AppImage builds (not `continuous` tag) - Per-asset size budget: hard fail at 1 GB per asset - prerelease inferred from tag regex (-rc|-beta|-alpha|-dev|-snapshot) - workflow_dispatch dry_run input: builds all assets without publishing, skips Android + bump workflows - Tag-vs-libs.versions.toml assertion as first step in each matrix job - Android + Quartz jobs preserved; migrated to softprops/action-gh-release@v2 ## Package manager bump workflows (Homebrew + Winget) - .github/workflows/bump-homebrew.yml — macauley/action-homebrew-bump-cask on ubuntu-latest (saves macOS runner quota). Cask name: `amethyst-nostr`. - .github/workflows/bump-winget.yml — vedantmgoyal9/winget-releaser on windows-latest. PackageIdentifier: `VitorPamplona.Amethyst`. - Shared composite action .github/actions/assert-stable-release rejects draft/prerelease/malformed-tag releases at action boundary (defense in depth vs workflow-level `if:` alone). - Both workflows auto-open `release-ops`-labeled GH Issues on failure. - Concurrency groups per tag prevent re-fire races. - AUR + Scoop deferred to follow-up PR (unresolved ownership questions). ## Documentation - BUILDING.md: per-platform build commands, asset naming contract, release runbook, bootstrap runbook, troubleshooting, uninstall paths, incident response, fallback plans (macos-13 retirement, Homebrew Sept 2026 deadline) - README: expanded Download section with per-OS install matrix for 7 formats + 2 package managers. Deploying section points at BUILDING.md. ## Supply chain - .github/dependabot.yml: monthly bumps for github-actions ecosystem - All new third-party actions SHA-pinned: - softprops/action-gh-release v2.6.2 - macauley/action-homebrew-bump-cask v4.0.0 - vedantmgoyal9/winget-releaser v2 - nick-fields/retry v3.0.2 - linuxdeploy binary SHA256-verified against pinned release tag --- .../actions/assert-stable-release/action.yml | 44 ++ .github/dependabot.yml | 17 + .github/workflows/bump-homebrew.yml | 68 +++ .github/workflows/bump-winget.yml | 65 +++ .github/workflows/create-release.yml | 430 +++++++++--------- BUILDING.md | 395 ++++++++++++++++ README.md | 48 +- 7 files changed, 840 insertions(+), 227 deletions(-) create mode 100644 .github/actions/assert-stable-release/action.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/bump-homebrew.yml create mode 100644 .github/workflows/bump-winget.yml create mode 100644 BUILDING.md diff --git a/.github/actions/assert-stable-release/action.yml b/.github/actions/assert-stable-release/action.yml new file mode 100644 index 000000000..85358cb06 --- /dev/null +++ b/.github/actions/assert-stable-release/action.yml @@ -0,0 +1,44 @@ +name: Assert Stable Release +description: >- + Defense-in-depth guard for package-manager bump workflows. Re-validates + tag format, prerelease flag, and draft status before invoking third-party + actions that hold write credentials to external package manager repos + (Homebrew, Winget, AUR, Scoop). Prevents RC builds from reaching stable + channels even if the `release.released` event gating is bypassed. + +runs: + using: composite + steps: + - name: Assert release is stable + shell: bash + env: + TAG: ${{ github.event.release.tag_name }} + IS_PRERELEASE: ${{ github.event.release.prerelease }} + IS_DRAFT: ${{ github.event.release.draft }} + run: | + set -euo pipefail + echo "tag=$TAG prerelease=$IS_PRERELEASE draft=$IS_DRAFT" + + # Reject prerelease suffix even if GitHub's flag says false. + if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then + echo "::error::Tag $TAG contains prerelease suffix; refusing bump" + exit 1 + fi + + # Enforce strict vMAJOR.MINOR.PATCH format. + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Tag $TAG does not match vMAJOR.MINOR.PATCH" + exit 1 + fi + + # Draft releases must never trigger bumps. + if [[ "$IS_DRAFT" == "true" ]]; then + echo "::error::Release is draft; refusing bump" + exit 1 + fi + + # Prerelease flag cross-check (belt-and-suspenders with workflow-level `if:`). + if [[ "$IS_PRERELEASE" == "true" ]]; then + echo "::error::Release is prerelease; refusing bump" + exit 1 + fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0c0bdba38 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + # Auto-update SHA-pinned GitHub Actions across all workflows. + # Required for supply-chain safety — SHA pins only age well with active bumps. + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - release-ops + - dependencies + commit-message: + prefix: chore(actions) + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml new file mode 100644 index 000000000..993ffba5d --- /dev/null +++ b/.github/workflows/bump-homebrew.yml @@ -0,0 +1,68 @@ +name: Bump Homebrew Cask + +# Fires when a GH Release is published (not draft, not prerelease). +# `release.types: [released]` event fires only for stable releases — still +# double-checked by .github/actions/assert-stable-release for defense-in-depth. +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to bump (for manual recovery)' + required: true + type: string + +permissions: + contents: read + +concurrency: + # Serialize bumps per tag; do not cancel in-progress bumps. + group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + bump: + if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Re-assert stable release + if: github.event_name == 'release' + uses: ./.github/actions/assert-stable-release + + - name: Bump cask (push-or-update PR) + uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + token: ${{ secrets.HOMEBREW_TOKEN }} + tap: homebrew/cask + cask: amethyst-nostr + tag: ${{ github.event.release.tag_name || inputs.tag }} + + - name: Report failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[release-ops] bump-homebrew failed for ${tag}`, + body: [ + `Homebrew cask bump failed for release \`${tag}\`.`, + ``, + `- Run: ${runUrl}`, + `- Channel: Homebrew Cask (\`amethyst-nostr\`)`, + ``, + `Recovery options:`, + `1. Re-run the workflow once underlying issue is fixed`, + `2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``, + `3. File PR directly against Homebrew/homebrew-cask` + ].join('\n'), + labels: ['release-ops', 'bug'] + }); diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml new file mode 100644 index 000000000..06a806a2f --- /dev/null +++ b/.github/workflows/bump-winget.yml @@ -0,0 +1,65 @@ +name: Bump Winget Manifest + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to submit (for manual recovery)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: bump-winget-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + bump: + if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Re-assert stable release + if: github.event_name == 'release' + uses: ./.github/actions/assert-stable-release + + - name: Submit manifest to winget-pkgs + uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: VitorPamplona.Amethyst + version: ${{ github.event.release.tag_name || inputs.tag }} + # Asset naming contract: scripts/asset-name.sh + installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$' + token: ${{ secrets.WINGET_TOKEN }} + + - name: Report failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[release-ops] bump-winget failed for ${tag}`, + body: [ + `Winget manifest submission failed for release \`${tag}\`.`, + ``, + `- Run: ${runUrl}`, + `- Channel: Winget (\`VitorPamplona.Amethyst\`)`, + ``, + `Recovery options:`, + `1. Re-run the workflow once underlying issue is fixed`, + `2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``, + `3. File PR directly against microsoft/winget-pkgs` + ].join('\n'), + labels: ['release-ops', 'bug'] + }); diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index fc619bc3f..9a334fc8f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -3,31 +3,192 @@ name: Create Release Assets on: push: tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run: build assets but do not publish to GH Release or trigger bump workflows' + type: boolean + default: false + test_tag: + description: 'Synthetic tag name for dry-run (e.g. v1.08.0-dryrun); ignored on tag push' + type: string + default: 'v0.0.0-dryrun' permissions: contents: write -jobs: - create-release: - runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - steps: - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: false - prerelease: true +env: + # Asset naming contract: amethyst-desktop---. + # Single source of truth in scripts/asset-name.sh. + # linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below. + LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage + LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0 +jobs: + # --------------------------------------------------------------------------- + # Desktop build matrix. Each leg uploads directly to the GH Release via + # softprops/action-gh-release@v2 (upsert by tag_name). No artifact round-trip. + # --------------------------------------------------------------------------- + build-desktop: + strategy: + fail-fast: false + matrix: + include: + - { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" } + - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } + - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } + - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Resolve tag + version + id: ver + env: + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + TEST_TAG: ${{ github.event.inputs.test_tag || '' }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + TAG="${TEST_TAG:-v0.0.0-dryrun}" + else + TAG="${GITHUB_REF_NAME}" + fi + VER="${TAG#v}" + TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) + # On dry-run we only require that TOML has a version; on real tag push we require exact match. + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + if [[ "$TOML_VER" != "$VER" ]]; then + echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG" + exit 1 + fi + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "toml=$TOML_VER" >> "$GITHUB_OUTPUT" + + - name: Install RPM tooling (linux families) + if: startsWith(matrix.family, 'linux') + run: sudo apt-get update && sudo apt-get install -y rpm fakeroot + + - name: Fetch linuxdeploy (linux-portable only, SHA-verified) + if: matrix.family == 'linux-portable' + run: | + set -euo pipefail + curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o packaging/appimage/linuxdeploy-x86_64.AppImage + actual=$(sha256sum packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}') + if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then + echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual" + exit 1 + fi + chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage + # linuxdeploy needs FUSE; on newer runners, --appimage-extract-and-run is required. + # Bypass FUSE requirement by pre-extracting the AppImage. + (cd packaging/appimage && ./linuxdeploy-x86_64.AppImage --appimage-extract >/dev/null && \ + mv squashfs-root linuxdeploy-extracted && \ + ln -sf linuxdeploy-extracted/AppRun linuxdeploy-x86_64.bin && \ + chmod +x linuxdeploy-x86_64.bin) + + - name: Build desktop artifacts + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 + with: + max_attempts: 2 + timeout_minutes: 40 + command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }} + + - name: Build portable archives (windows + linux-portable) + if: matrix.family == 'windows' || matrix.family == 'linux-portable' + run: | + set -euo pipefail + VER="${{ steps.ver.outputs.version }}" + APP="desktopApp/build/compose/binaries/main-release/app" + mkdir -p desktopApp/build/portable + if [[ "${{ matrix.family }}" == "windows" ]]; then + ( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ ) + else + ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) + fi + + - name: Collect + rename assets + run: | + set -euo pipefail + # shellcheck source=scripts/asset-name.sh + source scripts/asset-name.sh + # linux-portable family holds AppImage + tar.gz, but we re-tag with plain `linux` for the asset name. + FAMILY="${{ matrix.family }}" + [[ "$FAMILY" == "linux-portable" ]] && FAMILY="linux" + mkdir -p dist + collect_assets "$FAMILY" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + + - name: Enforce asset size budget (1 GB per asset) + run: | + set -euo pipefail + fail=0 + for f in dist/*; do + if [[ -f "$f" ]]; then + size=$(wc -c < "$f") + mb=$(( size / 1048576 )) + if (( size > 1073741824 )); then + echo "::error file=$f::asset is ${mb} MB — exceeds 1 GB budget" + fail=1 + else + echo "OK: $f — ${mb} MB" + fi + fi + done + [[ "$fail" == 0 ]] + + - name: Classify release + id: classify + run: | + TAG="${{ steps.ver.outputs.tag }}" + if [[ "$TAG" =~ -(rc|beta|alpha|dev|dryrun|snapshot) ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload to GH Release (skip on dry-run) + if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + files: dist/* + tag_name: ${{ steps.ver.outputs.tag }} + prerelease: ${{ steps.classify.outputs.prerelease }} + draft: false + fail_on_unmatched_files: true + generate_release_notes: true + + - name: Dry-run summary + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' + run: | + echo "### Dry-run: ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + ls -la dist >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + # --------------------------------------------------------------------------- + # Android build + sign + direct-upload. Logic preserved from previous workflow; + # uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset. + # --------------------------------------------------------------------------- deploy-android: - needs: create-release + if: github.event_name != 'workflow_dispatch' # dry-run skips Android (tag-push only) runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code uses: actions/checkout@v6 @@ -44,9 +205,9 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: ${{ runner.os }}-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | - ${{ runner.os }}-gradle- + ${{ runner.os }}-android-gradle- - name: Build AAB run: ./gradlew clean bundleRelease --stacktrace @@ -98,141 +259,50 @@ jobs: env: BUILD_TOOLS_VERSION: "36.0.0" - # Google Play APK - - name: Upload Play APK Universal Asset - id: upload-release-asset-play-universal-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk - asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk - asset_content_type: application/zip + - name: Collect Android assets (rename to canonical scheme) + run: | + set -euo pipefail + mkdir -p dist + TAG="${GITHUB_REF_NAME}" - - name: Upload Play APK x86 Asset - id: upload-release-asset-play-x86-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk - asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk - asset_content_type: application/zip + # Play APKs (5 variants) + for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do + cp "amethyst/build/outputs/apk/play/release/amethyst-play-${variant}-release-unsigned-signed.apk" \ + "dist/amethyst-googleplay-${variant}-${TAG}.apk" + done - - name: Upload Play APK x86_64 Asset - id: upload-release-asset-play-x86-64-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk - asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk - asset_content_type: application/zip + # F-Droid APKs (5 variants) + for variant in universal x86 x86_64 arm64-v8a armeabi-v7a; do + cp "amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-${variant}-release-unsigned-signed.apk" \ + "dist/amethyst-fdroid-${variant}-${TAG}.apk" + done - - name: Upload Play APK arm64-v8a Asset - id: upload-release-asset-play-arm64-v8a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk - asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk - asset_content_type: application/zip + # AABs + cp "amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab" \ + "dist/amethyst-googleplay-${TAG}.aab" + cp "amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab" \ + "dist/amethyst-fdroid-${TAG}.aab" + ls -la dist - - name: Upload Play APK armeabi-v7a Asset - id: upload-release-asset-play-armeabi-v7a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk - asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk - asset_content_type: application/zip + - name: Classify release + id: classify + run: | + TAG="${GITHUB_REF_NAME}" + if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi - # F-Droid APK - - name: Upload F-Droid APK Universal Asset - id: upload-release-asset-fdroid-universal-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Android assets to GH Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk - asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK x86 Asset - id: upload-release-asset-fdroid-x86-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk - asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK x86_64 Asset - id: upload-release-asset-fdroid-x86-64-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk - asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK arm64-v8a Asset - id: upload-release-asset-fdroid-arm64-v8a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk - asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk - asset_content_type: application/zip - - - name: Upload F-Droid APK armeabi-v7a Asset - id: upload-release-asset-fdroid-armeabi-v7a-apk - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk - asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk - asset_content_type: application/zip - - # Google Play AAB - - name: Upload Google Play AAB Asset - id: upload-release-asset-play-aab - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab - asset_name: amethyst-googleplay-${{ github.ref_name }}.aab - asset_content_type: application/zip - - # FDroid AAB - - name: Upload F-Droid AAB Asset - id: upload-release-asset-fdroid-aab - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab - asset_name: amethyst-fdroid-${{ github.ref_name }}.aab - asset_content_type: application/zip + files: dist/* + tag_name: ${{ github.ref_name }} + prerelease: ${{ steps.classify.outputs.prerelease }} + draft: false + fail_on_unmatched_files: true + generate_release_notes: true - name: Publish Quartz Lib run: ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache @@ -241,65 +311,3 @@ jobs: ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} - - deploy-desktop: - needs: create-release - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - task: packageDeb - format: deb - platform: linux - - os: macos-latest - task: packageDmg - format: dmg - platform: macos - - os: windows-latest - task: packageMsi - format: msi - platform: windows - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: 21 - - - name: Cache gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Build Desktop Distribution - run: ./gradlew :desktopApp:${{ matrix.task }} - - - name: Find distribution file - id: find-dist - run: | - DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1) - echo "path=$DIST_FILE" >> $GITHUB_OUTPUT - echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT - - - name: Upload Desktop Distribution to Release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ${{ steps.find-dist.outputs.path }} - asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }} - asset_content_type: application/octet-stream diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 000000000..611db4f12 --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,395 @@ +# Building Amethyst Desktop + +This guide covers building Amethyst Desktop from source, the release pipeline, +and one-time bootstrap steps for distribution channels. + +- [Prerequisites](#prerequisites) +- [Clone + first build](#clone--first-build) +- [Per-format build commands](#per-format-build-commands) +- [Asset naming contract](#asset-naming-contract) +- [Release runbook](#release-runbook) +- [Bootstrap runbook (one-time)](#bootstrap-runbook-one-time) +- [Troubleshooting installs](#troubleshooting-installs) +- [Uninstall + state paths](#uninstall--state-paths) +- [Incident response](#incident-response) +- [Fallback plans](#fallback-plans) + +--- + +## Prerequisites + +All platforms: + +- **JDK 21** (Zulu or Temurin recommended) +- **Git** + +Platform-specific: + +- **macOS**: Xcode Command Line Tools (`xcode-select --install`) +- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` +- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage + +Install Linux RPM tooling: + +```bash +# Debian/Ubuntu +sudo apt-get install -y rpm fakeroot + +# Fedora +sudo dnf install -y rpm-build +``` + +Install linuxdeploy locally (CI fetches its own — SHA-verified): + +```bash +curl -fsSL -o packaging/appimage/linuxdeploy-x86_64.AppImage \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage +chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage +``` + +--- + +## Clone + first build + +```bash +git clone https://github.com/vitorpamplona/amethyst.git +cd amethyst + +# Dev loop (launches Amethyst Desktop) +./gradlew :desktopApp:run + +# Package for current OS +./gradlew :desktopApp:packageDistributionForCurrentOS +``` + +--- + +## Per-format build commands + +| Artifact | Command | Output | +|---|---|---| +| macOS DMG (host arch) | `./gradlew :desktopApp:packageReleaseDmg` | `desktopApp/build/compose/binaries/main-release/dmg/Amethyst-*.dmg` | +| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` | +| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` | +| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` | +| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` | +| Windows `.zip` portable | See below (inline `7z`) | — | +| Linux `.tar.gz` portable | See below (inline `tar`) | — | + +**Inline portable archives** (run after `createReleaseDistributable`): + +```bash +./gradlew :desktopApp:createReleaseDistributable + +# Linux tar.gz +VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) +( cd desktopApp/build/compose/binaries/main-release/app \ + && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) + +# Windows .zip (PowerShell) +Compress-Archive -Path desktopApp\build\compose\binaries\main-release\app\Amethyst ` + -DestinationPath "desktopApp\build\portable\amethyst-desktop-$env:VER-windows-x64.zip" +``` + +Cross-platform architecture note: **`jpackage` cannot cross-compile**. An Intel +DMG must be built on `macos-13` (x64); an ARM DMG must be built on `macos-14` +or later. CI runs both. + +--- + +## Asset naming contract + +All GH Release assets follow: + +``` +amethyst-desktop---. +``` + +Where: + +| Field | Values | +|---|---| +| `` | Tag stripped of leading `v` (e.g. `1.08.0`) | +| `` | `macos`, `windows`, `linux` | +| `` | `x64`, `arm64` | +| `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` | + +Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh). +Package manager manifests (Homebrew cask, Winget) depend on this exact scheme — +any change is a breaking contract. + +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-linux-x64.AppImage` + +--- + +## Release runbook + +The release flow is driven by a tag push. Every cut ships Android + Desktop + +Quartz library in one pipeline. + +1. **Bump the app version** in `gradle/libs.versions.toml`: + + ```toml + [versions] + app = "1.08.1" # new semver + ``` + +2. **Bump Android `versionCode`** in `amethyst/build.gradle` (monotonic integer, + must increment even for same `versionName`): + + ```groovy + versionCode = 443 + versionName = generateVersionName(libs.versions.app.get()) + ``` + +3. **Commit + tag + push**: + + ```bash + git commit -am "chore(release): 1.08.1" + git tag -s v1.08.1 -m "Release 1.08.1" + git push && git push --tags + ``` + +4. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). + +5. **Verify**: + - GH Release contains 8 desktop assets + 12 Android assets + - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) + - Intel + ARM DMGs both present + - Android flow unchanged + +6. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`, + or `-snapshot` is auto-classified as prerelease. Stable tags trigger the + Homebrew + Winget bump workflows. + +### Dry-run (no tag push) + +Use `workflow_dispatch` to exercise the full matrix without publishing: + +```bash +gh workflow run create-release.yml \ + -f dry_run=true \ + -f test_tag=v0.0.0-dryrun \ + --ref feat/my-branch +``` + +Assets are built and size-checked, but not uploaded; bump workflows do not +fire. Use for pre-merge validation of workflow changes. + +### Version constraint: tag must match `libs.versions.toml` + +The first step in each build-desktop matrix job asserts: + +``` +tag (stripped of 'v') == gradle/libs.versions.toml [versions] app +``` + +If they drift, the workflow fails fast. Always bump the TOML first, then tag. + +### NEVER change Windows `upgradeUuid` + +`desktopApp/build.gradle.kts:upgradeUuid` is the MSI product family GUID. +Changing it breaks in-place upgrades for existing Windows users — they must +uninstall before a new release. Leave it alone forever. + +--- + +## Bootstrap runbook (one-time) + +### Secrets to provision in GitHub repo settings + +| Secret | Purpose | Scope | +|---|---|---| +| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry | +| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | + +All existing secrets (`SIGNING_KEY`, `SONATYPE_USERNAME`, etc.) remain +unchanged. + +Rotate both on a 90-day cadence. Owner: assigned via `docs/RELEASE_OPS.md` +or equivalent issue tracker. On rotation, paste new token and run +`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify. + +### Homebrew cask (one-time initial PR) + +```bash +brew bump-cask-pr amethyst-nostr \ + --version 1.08.0 \ + --url "https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-macos-arm64.dmg" +``` + +The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a +tiling window manager). After the first PR is merged, `bump-homebrew.yml` +auto-submits new version bumps on each stable release. + +### Winget (one-time initial submission) + +```bash +wingetcreate new \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-windows-x64.msi +``` + +Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is +merged into `microsoft/winget-pkgs`, `bump-winget.yml` auto-submits new +version manifests. + +--- + +## Troubleshooting installs + +### macOS — Gatekeeper "damaged and can't be opened" + +Amethyst Desktop is currently unsigned. First-time launch requires: + +1. **Right-click → Open** on the app (don't double-click) — then click **Open** on the Gatekeeper dialog +2. Or: `xattr -cr /Applications/Amethyst.app` to strip quarantine +3. Or: System Settings → Privacy & Security → "Open Anyway" after a blocked launch + +Recommended path: install via Homebrew (`brew install --cask amethyst-nostr`) +— cask flow handles this seamlessly. + +### Windows — SmartScreen "Windows protected your PC" + +Amethyst Desktop is currently unsigned (no Authenticode). First-time launch: + +1. Click **More info** on the SmartScreen dialog +2. Click **Run anyway** + +Alternatively use `winget install VitorPamplona.Amethyst` — winget install +bypasses the UI dialog after accepting the installer's inherent trust. + +### Linux AppImage won't execute + +```bash +chmod +x Amethyst-*.AppImage +./Amethyst-*.AppImage +``` + +On Fedora Silverblue / very minimal distros, FUSE might be missing. Use +`--appimage-extract-and-run`: + +```bash +./Amethyst-*.AppImage --appimage-extract-and-run +``` + +--- + +## Uninstall + state paths + +State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb, +.rpm, AppImage, tar.gz). Switching channels does not duplicate data but may +expose downgrade migration risks — **prefer a single install channel per +machine**. + +| OS | App location | State directories | +|---|---|---| +| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`
`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`
`~/Library/Caches/Amethyst` | +| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`
`%LOCALAPPDATA%\Amethyst` | +| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`
`~/.local/share/amethyst`
`~/.cache/amethyst` | +| Linux (AppImage/tar.gz) | user-chosen | Same as above | + +Uninstall: + +- Homebrew: `brew uninstall --cask amethyst-nostr && brew zap amethyst-nostr` +- Winget: `winget uninstall VitorPamplona.Amethyst` +- .deb: `sudo apt remove amethyst` +- .rpm: `sudo dnf remove amethyst` +- AppImage / tar.gz: delete the file / extracted directory +- macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually + +--- + +## Incident response + +### Bad GH Release asset + +1. Immediately mark release as prerelease (pauses bump workflows): + ```bash + gh release edit v1.08.1 --prerelease + ``` +2. Delete the bad asset: + ```bash + gh release delete-asset v1.08.1 amethyst-desktop-1.08.1-macos-arm64.dmg --yes + ``` +3. Rebuild locally or rerun the failing matrix job: + ```bash + gh run rerun --failed + ``` +4. Flip back to stable once verified (re-fires bump workflows — confirm fix first): + ```bash + gh release edit v1.08.1 --prerelease=false + ``` + +### Bad build reached Homebrew + +**Preferred**: ship a point release (e.g. v1.08.2) — users on v1.08.1 get the +fix via `brew upgrade`. + +**Alternative**: close the open PR in `Homebrew/homebrew-cask` before merge, +or file a revert PR if already merged. Typical Homebrew turn-around: 1–2 days. + +### Bad build reached Winget + +Winget manifests are append-only — no hard unpublish. Options: + +1. Ship a point release (preferred — users upgrade via `winget upgrade`) +2. File a manifest-removal PR against `microsoft/winget-pkgs`. Moderator + review: 24–72h. + +### User-facing communication + +On any incident: + +1. Edit the release body on GitHub with a warning banner + workaround +2. Pin a GH Issue with downgrade instructions per channel +3. Announce via Nostr relay + project social channels + +--- + +## Fallback plans + +### macOS Intel runner retirement + +GitHub's `macos-13` runner will eventually be deprecated. Monitor + +for the deprecation date. When it hits: + +1. Drop the `macos-13` matrix entry from `.github/workflows/create-release.yml` +2. Add a cross-arch build step on `macos-14` using a bundled x64 JDK + `jpackage --mac-signing-prefix` shenanigans, OR accept that only Apple Silicon DMGs ship and direct Intel users to `winget` on a Parallels VM or to rebuild from source. +3. Update README install matrix to reflect the change. + +### Homebrew main-cask rejects unsigned app (post-Sept 1 2026) + +Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask` +on 2026-09-01. Before that date: + +**Option A**: Commit budget to Apple Developer Program ($99/yr), add +`signing { sign.set(true) }` + `notarization {}` blocks to +`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI. + +**Option B**: Pivot to a private Homebrew tap: + +```bash +# Create repo: vitorpamplona/homebrew-amethyst +# Update bump-homebrew.yml: +# tap: vitorpamplona/amethyst +# cask: amethyst-nostr +# Users install: brew tap vitorpamplona/amethyst && brew install --cask amethyst-nostr +``` + +Note: a private tap does NOT bypass Gatekeeper itself (macOS OS-level) — users +still see the "unsigned developer" dialog. Tap only sidesteps Homebrew's +internal policy. + +--- + +## Follow-up channels (separate PRs) + +- **AUR** (`amethyst-desktop-bin`) — blocked on AUR account ownership decision +- **Scoop** (Windows) — blocked on bucket strategy (own vs Extras) +- **Flathub** — deferred (moderate ongoing maintenance) diff --git a/README.md b/README.md index dcf7f0753..bd5cc3036 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Join the social network you control. ## Download and Install +### Android + [Get it on Zap Store](https://github.com/zapstore/zapstore/releases) @@ -33,6 +35,24 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) alt="Get it on Google Play" height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst) +### Desktop + +| OS | CLI install | Direct download | +|---|---|---| +| macOS (Apple Silicon) | `brew install --cask amethyst-nostr` | [.dmg arm64](https://github.com/vitorpamplona/amethyst/releases/latest) | +| macOS (Intel) | `brew install --cask amethyst-nostr` | [.dmg x64](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Windows 10/11 | `winget install VitorPamplona.Amethyst` | [.msi](https://github.com/vitorpamplona/amethyst/releases/latest) · [.zip portable](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Debian/Ubuntu | — | [.deb](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Fedora/RHEL/openSUSE | — | [.rpm](https://github.com/vitorpamplona/amethyst/releases/latest) | +| Any Linux | — | [AppImage](https://github.com/vitorpamplona/amethyst/releases/latest) · [.tar.gz](https://github.com/vitorpamplona/amethyst/releases/latest) | + +_Coming soon (separate PR): Scoop (Windows), AUR (Arch Linux)._ + +**Build from source:** see [BUILDING.md](BUILDING.md). + +**Install troubleshooting** (Gatekeeper / SmartScreen / AppImage): see +[BUILDING.md § Troubleshooting installs](BUILDING.md#troubleshooting-installs). + ## Supported Features @@ -249,22 +269,18 @@ For the Play build: ## Deploying -1. Generate a new signing key -``` -keytool -genkey -v -keystore -alias -keyalg RSA -keysize 2048 -validity 10000 -openssl base64 < | tr -d '\n' | tee some_signing_key.jks.base64.txt -``` -2. Create four Secret Key variables on your GitHub repository and fill in the signing key information - - `KEY_ALIAS` <- `` - - `KEY_PASSWORD` <- `` - - `KEY_STORE_PASSWORD` <- `` - - `SIGNING_KEY` <- the data from `` -3. Change the `versionCode` and `versionName` on `amethyst/build.gradle` -4. Commit and push. -5. Tag the commit with `v{x.x.x}` -6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file. -7. Add your CHANGE LOG to the description of the new release -8. Download the `aab` file and upload it to the PlayStore. +Full release + bootstrap runbooks (Android AAB upload, desktop packaging, +Homebrew cask, Winget manifest, Apple Developer signing budget time-box) live +in [BUILDING.md § Release runbook](BUILDING.md#release-runbook) and +[BUILDING.md § Bootstrap runbook (one-time)](BUILDING.md#bootstrap-runbook-one-time). + +TL;DR for cutting a release: + +1. Bump `app` in `gradle/libs.versions.toml` (e.g. `"1.08.1"`) +2. Bump `versionCode` in `amethyst/build.gradle` +3. `git commit -am "chore(release): 1.08.1" && git tag -s v1.08.1 && git push --tags` +4. Wait for `Create Release Assets` workflow — 20 Android assets + 8 desktop assets go live on GH Release; Homebrew + Winget auto-bump on stable tags +5. Upload AAB to Play Store manually (existing step) ## Using the Quartz library From 84c4b461a9373d9ada9e953cd8b2d1feba8402ef Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 11:03:01 +0300 Subject: [PATCH 3/4] fix(release): address code review findings (P1-P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 fixes (must-fix): - AppRun: VLC path corrected to usr/lib/app/linux/vlc (jpackage actual path), add VLC_PLUGIN_PATH env var for codec discovery - linuxdeploy: use APPIMAGE_EXTRACT_AND_RUN=1 env var to bypass FUSE on CI runners instead of fragile pre-extract + symlink approach - Prerelease classifier: inverted to positive-match stable format (^v[0-9]+\.[0-9]+\.[0-9]+$) across desktop, Android, and composite action — eliminates regex drift between allowlists - assert-stable-release: now accepts inputs (tag, is_prerelease, is_draft) so workflow_dispatch on bump workflows also validates P2 fixes (should-fix): - .gitignore: add linuxdeploy-*.AppImage and extracted dirs - RPM version: use replace("-", "~") instead of substringBefore("-") for correct RPM prerelease ordering (1.08.0~rc1 < 1.08.0) - linux-portable → linux alias moved into collect_assets() in scripts/asset-name.sh (single source of truth) - Android cache key: add gradle/libs.versions.toml to hashFiles - r0adkll/sign-android-release: SHA-pinned to 349ebdef (v1) P3 fixes (nice-to-have): - LINUXDEPLOY_OUTPUT_VERSION env var replaced with APPIMAGE_EXTRACT_AND_RUN (misleading comment + redundant var) - nick-fields/retry timeout: 40m → 15m (surface real hangs) - generate_release_notes: true only on Android job (avoid last-writer-wins race across 6 concurrent uploaders) - apt-get install rpm: tightened guard to matrix.family == 'linux' (linux-portable leg doesn't need rpm tooling) --- .../actions/assert-stable-release/action.yml | 33 ++++++++------ .github/workflows/bump-homebrew.yml | 5 ++- .github/workflows/bump-winget.yml | 5 ++- .github/workflows/create-release.yml | 45 ++++++++----------- .gitignore | 5 +++ desktopApp/build.gradle.kts | 14 +++--- packaging/appimage/AppRun | 8 ++-- scripts/asset-name.sh | 2 + 8 files changed, 65 insertions(+), 52 deletions(-) diff --git a/.github/actions/assert-stable-release/action.yml b/.github/actions/assert-stable-release/action.yml index 85358cb06..9d754a447 100644 --- a/.github/actions/assert-stable-release/action.yml +++ b/.github/actions/assert-stable-release/action.yml @@ -2,9 +2,20 @@ name: Assert Stable Release description: >- Defense-in-depth guard for package-manager bump workflows. Re-validates tag format, prerelease flag, and draft status before invoking third-party - actions that hold write credentials to external package manager repos - (Homebrew, Winget, AUR, Scoop). Prevents RC builds from reaching stable - channels even if the `release.released` event gating is bypassed. + actions that hold write credentials to external package manager repos. + +inputs: + tag: + description: "Tag to validate (e.g. v1.08.0)" + required: true + is_prerelease: + description: "Whether the release is marked as prerelease (empty string treated as false)" + required: false + default: "false" + is_draft: + description: "Whether the release is a draft (empty string treated as false)" + required: false + default: "false" runs: using: composite @@ -12,22 +23,16 @@ runs: - name: Assert release is stable shell: bash env: - TAG: ${{ github.event.release.tag_name }} - IS_PRERELEASE: ${{ github.event.release.prerelease }} - IS_DRAFT: ${{ github.event.release.draft }} + TAG: ${{ inputs.tag }} + IS_PRERELEASE: ${{ inputs.is_prerelease }} + IS_DRAFT: ${{ inputs.is_draft }} run: | set -euo pipefail echo "tag=$TAG prerelease=$IS_PRERELEASE draft=$IS_DRAFT" - # Reject prerelease suffix even if GitHub's flag says false. - if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then - echo "::error::Tag $TAG contains prerelease suffix; refusing bump" - exit 1 - fi - - # Enforce strict vMAJOR.MINOR.PATCH format. + # Stable = exactly vMAJOR.MINOR.PATCH; reject everything else. if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Tag $TAG does not match vMAJOR.MINOR.PATCH" + echo "::error::Tag $TAG does not match stable vMAJOR.MINOR.PATCH format; refusing bump" exit 1 fi diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index 993ffba5d..478409386 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -31,8 +31,11 @@ jobs: uses: actions/checkout@v6 - name: Re-assert stable release - if: github.event_name == 'release' uses: ./.github/actions/assert-stable-release + with: + tag: ${{ github.event.release.tag_name || inputs.tag }} + is_prerelease: ${{ github.event.release.prerelease || 'false' }} + is_draft: ${{ github.event.release.draft || 'false' }} - name: Bump cask (push-or-update PR) uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml index 06a806a2f..be3cc9115 100644 --- a/.github/workflows/bump-winget.yml +++ b/.github/workflows/bump-winget.yml @@ -27,8 +27,11 @@ jobs: uses: actions/checkout@v6 - name: Re-assert stable release - if: github.event_name == 'release' uses: ./.github/actions/assert-stable-release + with: + tag: ${{ github.event.release.tag_name || inputs.tag }} + is_prerelease: ${{ github.event.release.prerelease || 'false' }} + is_draft: ${{ github.event.release.draft || 'false' }} - name: Submit manifest to winget-pkgs uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 9a334fc8f..8b81801f2 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -80,8 +80,8 @@ jobs: echo "version=$VER" >> "$GITHUB_OUTPUT" echo "toml=$TOML_VER" >> "$GITHUB_OUTPUT" - - name: Install RPM tooling (linux families) - if: startsWith(matrix.family, 'linux') + - name: Install RPM tooling (deb+rpm leg only) + if: matrix.family == 'linux' run: sudo apt-get update && sudo apt-get install -y rpm fakeroot - name: Fetch linuxdeploy (linux-portable only, SHA-verified) @@ -95,18 +95,12 @@ jobs: exit 1 fi chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage - # linuxdeploy needs FUSE; on newer runners, --appimage-extract-and-run is required. - # Bypass FUSE requirement by pre-extracting the AppImage. - (cd packaging/appimage && ./linuxdeploy-x86_64.AppImage --appimage-extract >/dev/null && \ - mv squashfs-root linuxdeploy-extracted && \ - ln -sf linuxdeploy-extracted/AppRun linuxdeploy-x86_64.bin && \ - chmod +x linuxdeploy-x86_64.bin) - name: Build desktop artifacts uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: max_attempts: 2 - timeout_minutes: 40 + timeout_minutes: 15 command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }} - name: Build portable archives (windows + linux-portable) @@ -127,11 +121,8 @@ jobs: set -euo pipefail # shellcheck source=scripts/asset-name.sh source scripts/asset-name.sh - # linux-portable family holds AppImage + tar.gz, but we re-tag with plain `linux` for the asset name. - FAMILY="${{ matrix.family }}" - [[ "$FAMILY" == "linux-portable" ]] && FAMILY="linux" - mkdir -p dist - collect_assets "$FAMILY" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + # collect_assets normalizes linux-portable → linux internally. + collect_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist - name: Enforce asset size budget (1 GB per asset) run: | @@ -155,10 +146,11 @@ jobs: id: classify run: | TAG="${{ steps.ver.outputs.tag }}" - if [[ "$TAG" =~ -(rc|beta|alpha|dev|dryrun|snapshot) ]]; then - echo "prerelease=true" >> "$GITHUB_OUTPUT" - else + # Stable = exactly vMAJOR.MINOR.PATCH; everything else is prerelease. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" fi - name: Upload to GH Release (skip on dry-run) @@ -170,7 +162,7 @@ jobs: prerelease: ${{ steps.classify.outputs.prerelease }} draft: false fail_on_unmatched_files: true - generate_release_notes: true + generate_release_notes: false # Android job writes release notes (last-writer-wins race) - name: Dry-run summary if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' @@ -205,7 +197,7 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: ${{ runner.os }}-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }} restore-keys: | ${{ runner.os }}-android-gradle- @@ -213,7 +205,7 @@ jobs: run: ./gradlew clean bundleRelease --stacktrace - name: Sign AAB (Google Play) - uses: r0adkll/sign-android-release@v1 + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1 with: releaseDirectory: amethyst/build/outputs/bundle/playRelease signingKeyBase64: ${{ secrets.SIGNING_KEY }} @@ -224,7 +216,7 @@ jobs: BUILD_TOOLS_VERSION: "36.0.0" - name: Sign AAB (F-Droid) - uses: r0adkll/sign-android-release@v1 + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1 with: releaseDirectory: amethyst/build/outputs/bundle/fdroidRelease signingKeyBase64: ${{ secrets.SIGNING_KEY }} @@ -238,7 +230,7 @@ jobs: run: ./gradlew assembleRelease --stacktrace - name: Sign APK (Google Play) - uses: r0adkll/sign-android-release@v1 + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1 with: releaseDirectory: amethyst/build/outputs/apk/play/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} @@ -249,7 +241,7 @@ jobs: BUILD_TOOLS_VERSION: "36.0.0" - name: Sign APK (F-Droid) - uses: r0adkll/sign-android-release@v1 + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1 with: releaseDirectory: amethyst/build/outputs/apk/fdroid/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} @@ -288,10 +280,11 @@ jobs: id: classify run: | TAG="${GITHUB_REF_NAME}" - if [[ "$TAG" =~ -(rc|beta|alpha|dev|snapshot) ]]; then - echo "prerelease=true" >> "$GITHUB_OUTPUT" - else + # Stable = exactly vMAJOR.MINOR.PATCH; everything else is prerelease. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" fi - name: Upload Android assets to GH Release diff --git a/.gitignore b/.gitignore index aa8cf0a66..387265dae 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,11 @@ desktopApp/src/jvmMain/appResources/linux/ desktopApp/src/jvmMain/appResources/macos/ desktopApp/src/jvmMain/appResources/windows/ +# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed) +packaging/appimage/linuxdeploy-x86_64.AppImage +packaging/appimage/linuxdeploy-extracted/ +packaging/appimage/squashfs-root/ + # Git worktrees .worktrees/ .claude/worktrees/ diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 742051e2e..bd014b97b 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -9,10 +9,9 @@ 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). +// RPM rejects dashes in version strings — replace with tilde (~) which RPM uses +// for prerelease ordering: 1.08.0~rc1 < 1.08.0 per RPM version comparison rules. val appVersion: String = project.version.toString() -val appVersionRpm: String = appVersion.substringBefore("-") sourceSets { main { @@ -120,8 +119,8 @@ compose.desktop { appCategory = "Network" debMaintainer = "vitor@vitorpamplona.com" rpmLicenseType = "MIT" - // RPM version field rejects dashes; strip prerelease suffix for RPM builds. - rpmPackageVersion = appVersionRpm + // RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering). + rpmPackageVersion = appVersion.replace("-", "~") } } } @@ -201,6 +200,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) { ) environment("OUTPUT", outFile.get().asFile.absolutePath) environment("ARCH", "x86_64") - // Suppress linuxdeploy's verbose library-scanner output; keep errors. - environment("LINUXDEPLOY_OUTPUT_VERSION", appVersion) + // Bypass FUSE requirement on CI runners (ubuntu-latest lacks libfuse.so.2). + // AppImage standard env var: extracts + runs without mounting. + environment("APPIMAGE_EXTRACT_AND_RUN", "1") } diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun index 9b2fb923d..d4065af01 100755 --- a/packaging/appimage/AppRun +++ b/packaging/appimage/AppRun @@ -1,9 +1,11 @@ #!/bin/bash # AppImage launcher for Amethyst Desktop. -# Sets LD_LIBRARY_PATH to find bundled VLC natives (vlcj dlopens libvlc.so at runtime). -set -e +# Sets LD_LIBRARY_PATH so vlcj finds bundled libvlc.so at runtime. +# jpackage puts app resources at usr/lib/app//vlc/ inside the AppDir. +set -eu 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 LD_LIBRARY_PATH="${HERE}/usr/lib/app/linux/vlc:${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" +export VLC_PLUGIN_PATH="${HERE}/usr/lib/app/linux/vlc/plugins" export PATH="${HERE}/usr/bin:${PATH}" export APPDIR="${HERE}" exec "${HERE}/usr/bin/Amethyst" "$@" diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index 9010e1d02..eab8f6bed 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -38,6 +38,8 @@ asset_name() { # Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives). collect_assets() { local family="$1" arch="$2" version="$3" dest="$4" + # Normalize internal matrix family aliases to canonical asset-name families. + [[ "$family" == "linux-portable" ]] && family="linux" mkdir -p "$dest" shopt -s nullglob From 54dffddf2fdac7ff5c04d432e3f39cf0536fa3a4 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 11:32:31 +0300 Subject: [PATCH 4/4] chore: remove plan doc from PR (local artifact) --- ...desktop-multiplatform-distribution-plan.md | 1024 ----------------- 1 file changed, 1024 deletions(-) delete mode 100644 docs/plans/2026-04-16-feat-desktop-multiplatform-distribution-plan.md 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 538cdec2d..000000000 --- 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)