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
65 KiB
title, type, status, date, origin, deepened
| title | type | status | date | origin | deepened |
|---|---|---|---|---|---|
| Desktop Multi-Platform Distribution | feat | active | 2026-04-16 | docs/brainstorms/2026-04-16-desktop-multiplatform-distribution-brainstorm.md | 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.txtaggregation (Amethyst Android releases have no checksums; follow existing convention — no cosign/GPG on release).- Dropped: draft→publish flip (current
create-release.ymlalready uses direct single-shot publish withdraft: false, prerelease: true; align with existing pattern).- Dropped:
createPortableTarGz/createPortableZipGradle tasks (inlinetar/zipin CI aftercreateReleaseDistributable).- Dropped:
verify-versionas separate job (merged as first step in each matrix job).- Dropped: 8 of 11 template files — Homebrew cask rewritten by
action-homebrew-bump-caskfrom live cask; Winget manifests generated bywinget-releaser. Only 3 build-input files retained for AppImage.- Added P0: SHA-pin all third-party GH Actions (tj-actions March 2025 precedent); verify
appimagetoolSHA256 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:
linuxdeployinstead of rawappimagetoolfor JVM+VLC library bundling; build AppImage onubuntu-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'screateReleaseDistributable). Secret namesHOMEBREW_PAT/WINGET_PAT→HOMEBREW_TOKEN/WINGET_TOKEN(matches existingSONATYPE_PASSWORDpattern).
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-latestreturns 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@v1is archived; future GHA runner changes could break releases silently. - Version drift is visible: if we ship to Homebrew showing
1.0.0while Android is1.06.3, users perceive the project as abandoned.
Proposed Solution [REFINED after deepen]
A single large PR landing:
- Version source-of-truth in
gradle/libs.versions.toml([versions] app = "1.06.3"), consumed by Android + Desktop modules. AndroidversionCodestays locally bumped inamethyst/build.gradle; onlyversionName/packageVersionshare the source.project.versionset at rootallprojects{}so subprojects inherit — avoids multi-module catalog-resolution drift. - Expanded Gradle packaging in
desktopApp/build.gradle.kts— addTargetFormat.Rpm; add one custom Gradle taskcreateReleaseAppImage(AppImage vialinuxdeploywrappingcreateReleaseDistributable). Portable tar.gz/zip produced by inlinetar/zipin CI aftercreateReleaseDistributable— no Gradle task needed. - Rewritten
.github/workflows/create-release.yml— replace deprecatedactions/create-release@v1+actions/upload-release-asset@v1withsoftprops/action-gh-release@v2(SHA-pinned). Expand desktop matrix tomacos-13(Intel) +macos-14(ARM) +windows-latest+ubuntu-latest. Matrix jobs upload directly to release viasoftprops/action-gh-release@v2(no intermediate artifact round-trip — saves 8–12 min and 1.5GB double-transfer). Produce 8 desktop assets. NoSHA256SUMS.txt(follows existing Amethyst convention — no checksums file on current releases). Release published directly (no draft→publish flip — follows existingcreate-release.yml:25single-shot pattern). - Two new auto-bump workflows (AUR + Scoop deferred):
.github/workflows/bump-homebrew.yml—action-homebrew-bump-caskonubuntu-latest(brew works on Linux; saves macOS runner quota).github/workflows/bump-winget.yml—vedantmgoyal9/winget-releaseronwindows-latest- Both gated on
release.types: [released]+if: github.event.release.prerelease == falseat 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_runtrigger variant where possible, gating oncreate-releaseworkflow success.
- 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×100icon.png)- Homebrew cask: lives in
Homebrew/homebrew-caskafter initial manual PR;action-homebrew-bump-caskre-fetches and rewrites it. No.tmplin our repo. - Winget manifests: generated by
winget-releaserfrom prior version on each release. No.tmplin our repo.
- Composite action
.github/actions/assert-stable-release/action.yml— shared prerelease + tag-format re-assertion, called by both bump workflows. Prevents drift across workflows. - New
BUILDING.mdat repo root: prereqs, per-platform build commands, release runbook (maintainer-facing), bootstrap runbook (one-time), troubleshooting (Gatekeeper, SmartScreen), uninstall + state paths per OS. - 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— addapp = "1.06.3"under[versions]amethyst/build.gradle— readversionNamefrom catalogdesktopApp/build.gradle.kts— readpackageVersionfrom catalog; also setrpmPackageVersionwith dashes stripped (RPM constraint)- Root
build.gradle— optionally setallprojects { version = libs.versions.app.get() }
Pseudo-code (desktopApp/build.gradle.kts):
// desktopApp/build.gradle.kts
val appVersion = libs.versions.app.get()
val appVersionRpm = appVersion.substringBefore("-") // RPM forbids '-'
project.version = appVersion
compose.desktop {
application {
nativeDistributions {
targetFormats(
TargetFormat.Dmg,
TargetFormat.Msi,
TargetFormat.Deb,
TargetFormat.Rpm,
)
packageName = "Amethyst"
packageVersion = appVersion
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
rpmPackageVersion = appVersionRpm
menuGroup = "Network"
appCategory = "Network"
debMaintainer = "Amethyst Contributors <contact@amethyst>" // open question: email
rpmLicenseType = "MIT"
}
// ... existing macOS + windows blocks unchanged
}
}
}
amethyst/build.gradle wiring:
// 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:
-
RPM — add
TargetFormat.Rpmto targetFormats list (done in Phase 1 pseudo-code above). Compose will generatepackageReleaseRpmtask. Ubuntu runner needsapt-get install -y rpmpre-step. -
AppImage — custom task
createReleaseAppImage.TargetFormat.AppImagein Compose 1.10.x is broken (CMP-7101) — do NOT use. Uselinuxdeploy(not rawappimagetool) because it auto-scansusr/lib/for missing libraries, handles rpath for bundled JVM, and bundles VLC.sofiles reliably:
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. SetsLD_LIBRARY_PATHincludingusr/lib/vlcsovlcjfinds libvlc at runtime:#!/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, includesMimeType=x-scheme-handler/nostr;fornostr:URI handling (future, non-breaking)amethyst.png— 512×512 icon (scale from existing 100×100icon.pngusing ImageMagickconvert icon.png -resize 512x512 amethyst.png)
Build linuxdeploy fetch in CI (SHA-pinned, not continuous):
- 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.
- Portable tar.gz (Linux) + zip (Windows) — no Gradle tasks. Inline
tar/zipin CI aftercreateReleaseDistributable:
# 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:packageReleaseRpmon Ubuntu withrpminstalled → valid.rpm./gradlew :desktopApp:createReleaseAppImageon Ubuntu 22.04 → validAmethyst-*-x86_64.AppImage(glibc 2.35 target;linuxdeploybundles deps for compat; test on Fedora 40 + Alpine)- Inline
taron Linux → validamethyst-desktop-*-linux-x64.tar.gz; extract +./bin/Amethystruns - Inline
zipon Windows → validamethyst-desktop-*-windows-x64.zip; extract +Amethyst.exeruns 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@v1withsoftprops/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 intermediateupload-artifactround-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 prereleaseinferred from tag regex:-rc|-beta|-alpha→ prerelease, otherwise stable- Tag-vs-catalog assertion: inline first step in each matrix job (no separate
verify-versionjob — 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: 30per matrix leg - Split ubuntu job into two matrix legs (deb+rpm, then AppImage+tar.gz) — halves critical-path time
Pseudo-code (abbreviated, SHA placeholders as <SHA>):
# .github/workflows/create-release.yml
name: Create Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
build-desktop:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-13, tasks: "packageReleaseDmg", arch: x64, family: macos }
- { os: macos-14, tasks: "packageReleaseDmg", arch: arm64, family: macos }
- { os: windows-latest, tasks: "packageReleaseMsi createReleaseDistributable", arch: x64, family: windows }
- { os: ubuntu-latest, tasks: "packageReleaseDeb packageReleaseRpm", arch: x64, family: linux-installers }
- { os: ubuntu-latest, tasks: "createReleaseAppImage createReleaseDistributable", arch: x64, family: linux-portable }
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults: { run: { shell: bash } }
steps:
- uses: actions/checkout@<SHA> # SHA-pinned; Dependabot-managed
- uses: actions/setup-java@<SHA>
with: { distribution: zulu, java-version: 21 }
- name: Assert tag matches libs.versions.toml
run: |
TOML_VER=$(./gradlew -q printAppVersion) # small Gradle task reads libs.versions.app
TAG_VER="${GITHUB_REF_NAME#v}"
[[ "$TOML_VER" == "$TAG_VER" ]] || { echo "::error::catalog=$TOML_VER tag=$TAG_VER"; exit 1; }
- name: Install rpm tooling (linux only)
if: startsWith(matrix.family, 'linux')
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Fetch linuxdeploy (linux-portable only, SHA-pinned)
if: matrix.family == 'linux-portable'
run: |
set -euo pipefail
curl -fsSL --retry 3 \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage" \
-o packaging/appimage/linuxdeploy-x86_64.AppImage
echo "${LINUXDEPLOY_SHA256} packaging/appimage/linuxdeploy-x86_64.AppImage" | sha256sum -c -
chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage
env:
LINUXDEPLOY_SHA256: <known-good-hash>
- uses: nick-fields/retry@<SHA>
with:
max_attempts: 3
timeout_minutes: 25
command: ./gradlew :desktopApp:${{ matrix.tasks }} --no-daemon
- name: Build inline portable archives
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
run: |
set -euo pipefail
VER="${GITHUB_REF_NAME#v}"
APP="desktopApp/build/compose/binaries/main-release/app"
if [[ "${{ matrix.family }}" == "windows" ]]; then
(cd "$APP" && powershell -c "Compress-Archive -Path Amethyst -DestinationPath ../../../../../amethyst-desktop-${VER}-windows-x64.zip")
else
(cd "$APP" && tar czf "../../../../../amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/)
fi
- name: Collect + rename assets
id: collect
run: |
set -euo pipefail
VER="${GITHUB_REF_NAME#v}"
mkdir -p dist
source scripts/asset-name.sh # single source of truth (arch review A1)
collect_assets "${{ matrix.family }}" "${{ matrix.arch }}" "$VER" dist/
- name: Enforce asset size budget
run: |
for f in dist/*; do
size=$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f")
(( size <= 1073741824 )) || { echo "::error::$f is $(($size / 1048576)) MB (>1GB)"; exit 1; }
done
- name: Classify release
id: classify
run: |
if [[ "${GITHUB_REF_NAME}" =~ -(rc|beta|alpha) ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Upload to GH Release (direct)
uses: softprops/action-gh-release@<SHA>
with:
files: dist/*
prerelease: ${{ steps.classify.outputs.is_prerelease }}
draft: false
fail_on_unmatched_files: true
generate_release_notes: true
tag_name: ${{ github.ref_name }} # upsert — reruns are idempotent
deploy-android:
# unchanged from current workflow (keep existing logic + assert-tag step)
# ...
publish-quartz:
# unchanged
# ...
Key security & performance deltas:
- All
uses:pinned to 40-char SHA (per security audit P0.1) — Dependabot-managed updates linuxdeploy(notappimagetoolwithcontinuoustag) — versioned, SHA-verified (per security audit P0.2; performance audit too)nick-fields/retrywraps 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;
createReleaseDistributableruns once per leg but parallelizes (performance audit §2) scripts/asset-name.sh— single source for asset naming, consumed by workflow + bump jobs + BUILDING.md (arch review A1)
Asset naming contract (committed in BUILDING.md):
amethyst-desktop-<version>-<family>-<arch>.<ext>
Where:
<version>= tag stripped of leadingv(e.g.1.06.3)<family>∈macos,windows,linux<arch>∈x64,arm64<ext>∈dmg,msi,zip,deb,rpm,AppImage,tar.gz
Examples:
amethyst-desktop-1.06.3-macos-x64.dmgamethyst-desktop-1.06.3-macos-arm64.dmgamethyst-desktop-1.06.3-windows-x64.msiamethyst-desktop-1.06.3-windows-x64.zipamethyst-desktop-1.06.3-linux-x64.debamethyst-desktop-1.06.3-linux-x64.rpmamethyst-desktop-1.06.3-linux-x64.AppImageamethyst-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:
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:
name: Bump Homebrew Cask
on:
release:
types: [released] # only fires for non-prerelease
permissions: { contents: read }
concurrency:
group: bump-homebrew-${{ github.event.release.tag_name }}
cancel-in-progress: false
jobs:
bump:
if: github.event.release.prerelease == false
runs-on: ubuntu-latest # brew works on linux; saves macOS runner quota
steps:
- uses: actions/checkout@<SHA> # SHA-pinned; Dependabot-managed
- uses: ./.github/actions/assert-stable-release
- uses: macauley/action-homebrew-bump-cask@<SHA> # SHA-pinned
with:
token: ${{ secrets.HOMEBREW_TOKEN }}
tap: homebrew/cask
cask: amethyst-nostr
tag: ${{ github.ref }}
- name: Report failure
if: failure()
uses: actions/github-script@<SHA>
with:
script: |
github.rest.issues.create({
owner: context.repo.owner, repo: context.repo.repo,
title: `[release-ops] bump-homebrew failed for ${context.payload.release.tag_name}`,
body: `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
labels: ['release-ops', 'bug']
})
.github/workflows/bump-winget.yml:
name: Bump Winget Manifest
on:
release:
types: [released]
permissions: { contents: read }
concurrency:
group: bump-winget-${{ github.event.release.tag_name }}
cancel-in-progress: false
jobs:
bump:
if: github.event.release.prerelease == false
runs-on: windows-latest
steps:
- uses: actions/checkout@<SHA>
- uses: ./.github/actions/assert-stable-release
- uses: vedantmgoyal9/winget-releaser@<SHA> # SHA-pinned
with:
identifier: VitorPamplona.Amethyst
version: ${{ github.event.release.tag_name }}
installers-regex: '^amethyst-desktop-.*windows-x64\.msi$'
token: ${{ secrets.WINGET_TOKEN }}
- name: Report failure
if: failure()
uses: actions/github-script@<SHA>
with:
script: | # same issue-open pattern as above
Deferred to follow-up PR: bump-aur.yml, bump-scoop.yml — require maintainer to resolve AUR account owner + Scoop bucket strategy first (brainstorm Open Q1, Q2).
Phase 5 — Manifest files [REFINED: 3 files, not 11]
Only build-input files committed — package-manager manifests are generated by their respective bump actions from the live release:
| Path | Purpose |
|---|---|
packaging/appimage/AppRun |
AppImage launcher shell script (sets LD_LIBRARY_PATH incl. bundled VLC dylibs) |
packaging/appimage/amethyst.desktop |
AppImage XDG desktop entry |
packaging/appimage/amethyst.png |
512×512 AppImage icon (scale from existing icon.png) |
Homebrew cask: lives in Homebrew/homebrew-cask after initial manual PR (bootstrap); subsequent releases rewrite it via action-homebrew-bump-cask which re-fetches asset URLs and computes SHA256 itself.
Winget manifests: generated by vedantmgoyal9/winget-releaser from prior version on each release.
AppImage tooling: use linuxdeploy instead of raw appimagetool for JVM+VLC library bundling (auto-scans usr/lib/ and handles rpath). linuxdeploy binary pinned to a released version (not continuous) and SHA256-verified when fetched in CI — or committed to packaging/appimage/linuxdeploy-x86_64.AppImage for supply-chain hardening (GPL, redistributable).
Phase 6 — Documentation [REFINED]
New file: BUILDING.md (repo root) — sections:
- Prerequisites — JDK 21 (Temurin/Zulu), Git, per-platform tools (
rpm,fakeroot,linuxdeploy, WiX, Xcode CLI tools) - Cloning + initial build —
./gradlew :desktopApp:run(dev),./gradlew :desktopApp:packageDistributionForCurrentOS(package) - 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/)
- macOS (Intel or ARM):
- Asset naming contract — single source in
scripts/asset-name.sh(architectural review A1/A5) - Release runbook (maintainer-facing): bump
libs.versions.tomlapp, bump AndroidversionCodeinamethyst/build.gradle, commit, tag, push; workflow auto-publishes - Bootstrap runbook (one-time, maintainer-facing) — Homebrew + Winget only in this PR:
- Create
HOMEBREW_TOKEN(fine-grained PAT,Homebrew/homebrew-caskonly, 90d expiry), manual firstbrew bump-cask-pr amethyst-nostr - Create
WINGET_TOKEN(classic PAT,public_repo, 90d expiry), manual first submission viawingetcreate - 90-day rotation owner + calendar reminder (rotation runbook in BUILDING.md)
- AUR + Scoop bootstrap: deferred to follow-up PR
- Create
- Troubleshooting: macOS Gatekeeper (
xattr -cr, right-click Open), Windows SmartScreen ("More info → Run anyway"), Linux AppImage execute bit - Uninstall + state paths per OS (macOS
~/Library/Application Support/Amethyst, Windows%APPDATA%\Amethyst, Linux~/.config/amethyst) - 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 - Fallback plans:
- If
macos-13Intel runner retires: cross-build onmacos-14with explicit x64 JDK (runbook) - If Homebrew main-cask rejects unsigned (Sept 2026 enforcement): pivot to private tap
vitorpamplona/homebrew-amethyst
- If
Update: README.md — replace current ## Download and Install section:
## 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); AndroidversionCodestill 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:
- Intel macOS DMG actually runs on Intel hardware.
file Amethyst.app/Contents/MacOS/AmethystshowsMach-O 64-bit executable x86_64— not universal, not arm64. Manual: fresh Intel Mac, right-click Open, app launches, signs in to Nostr relay. - ARM macOS DMG runs on Apple Silicon without Rosetta.
fileshowsMach-O 64-bit executable arm64. Manual: fresh M-series Mac, VLC video note plays (validates VLC arm64 dylibs were bundled correctly — Risk R2). - 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/Amethystunless user opts to preserve. - Winget flow. Fresh Windows 11 VM:
winget install VitorPamplona.Amethyst→ app appears in Start Menu → launches → uninstall via Control Panel leaves no registry remnants underHKCU\Software\Amethyst. - AppImage on unknown distro. Fresh Alpine/Void/NixOS container:
chmod +x Amethyst-*.AppImage && ./Amethyst-*.AppImageworks (validates AppImage self-containment + glibc 2.27 compat). - Version contract. Push tag
v1.06.4wherelibs.versions.tomlsaysapp = "1.06.3"→verify-versionjob fails fast; no assets built. - Prerelease gating. Push
v1.06.3-rc1→ release marked prerelease → bump-homebrew/winget/aur workflows do NOT trigger. - Matrix partial failure. Simulate one runner failure → other 3 continue →
releasejob blocked → retry of failed matrix job → release publishes successfully.
Acceptance Criteria
Functional Requirements
Phase 1 — Version source-of-truth:
gradle/libs.versions.tomlcontains[versions] app = "<semver>"- Root
allprojects { version = libs.versions.app.get() }so subprojects inherit ./gradlew :desktopApp:packageDistributionForCurrentOSproduces asset withpackageVersionmatching catalog./gradlew :amethyst:assembleReleaseproduces APK withversionNamematching 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:packageReleaseRpmon Ubuntu withrpminstalled → valid.rpm;rpm -qlplists bundled VLC./gradlew :desktopApp:createReleaseAppImageon Ubuntu 22.04 → validAmethyst-*-x86_64.AppImage;chmod +x+ run launches app- Inline
tarin CI produces validamethyst-desktop-*-linux-x64.tar.gz; extract +./bin/Amethystruns - Inline
Compress-Archivein CI produces valid.zip; extract +Amethyst.exeruns without installed JRE - AppImage runs on Alpine/NixOS container (glibc compat;
linuxdeploybundles libs)
Phase 3 — Release workflow:
actions/create-release@v1andactions/upload-release-asset@v1removed;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: trueiff tag matchesv*-(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/ linuxdeployfetch is SHA-verified (or binary committed topackaging/appimage/)
Phase 4 — Auto-bump workflows (Homebrew + Winget only):
bump-homebrew.yml+bump-winget.ymlpresent; gated onrelease.types: [released]+prerelease == false- Shared composite action
.github/actions/assert-stable-releasere-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/AppRunpresent (shellcheck clean)packaging/appimage/amethyst.desktoppresent (desktop-file-validate clean)packaging/appimage/amethyst.pngpresent (≥ 512×512, valid PNG)- (Optional)
packaging/appimage/linuxdeploy-x86_64.AppImagecommitted for supply-chain hardening
Phase 6 — Docs:
BUILDING.mdat repo root; linked from README- README
## Download and Installincludes per-OS desktop matrix; AUR/Scoop marked "Coming soon" - README references
BUILDING.mdfor 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) andmacos-14(arm64) viafilecommand 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)
spotlessApplyclean on Kotlin changes- README renders correctly on GH
BUILDING.mdverified by a second contributor on fresh macOS + Windows + Linux VMs- Pre-merge matrix dry-run via
workflow_dispatchsucceeds 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
TargetFormatvalues - JDK 21 (already used)
ir.mahozad.vlc-setup0.1.0 (already used) — confirmed fetchesvlc-3.0.21-universal.dmgwith arm64+x86_64 multi-arch dylibs; works on both macos-13 and macos-14 runnerslinuxdeploySHA-pinned (fetched per-CI-run OR committed to repo)rpm+fakeroot(apt-installed on Ubuntu runner)
GH Actions dependencies (all SHA-pinned)
softprops/action-gh-release@<SHA>(v2.x)actions/checkout@<SHA>,actions/setup-java@<SHA>macauley/action-homebrew-bump-cask@<SHA>(v1.x)vedantmgoyal9/winget-releaser@<SHA>(v2.x)nick-fields/retry@<SHA>(for transient VLC download retries)actions/github-script@<SHA>(failure issue auto-open)- Dependabot config for
.github/workflows/to auto-PR SHA updates
Secrets to provision (one-time bootstrap by maintainer)
HOMEBREW_TOKEN— fine-grained PAT (scoped toHomebrew/homebrew-caskonly,Contents: write+Pull requests: write), 90d expiryWINGET_TOKEN— classic PAT withpublic_repo(winget-releaser requires classic), 90d expiry, dedicated bot account preferred
External prerequisites (bootstrap runbook in BUILDING.md)
- Homebrew cask
amethyst-nostrmerged toHomebrew/homebrew-caskvia manualbrew bump-cask-pronce (then auto-bumped) - Winget
VitorPamplona.Amethystsubmitted once viawingetcreate(then auto-bumped bywinget-releaser) LINUXDEPLOY_SHA256hash constant committed to workflow (update whenlinuxdeployversion 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 | 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 | — | — | 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 AndroidversionCode, 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:
- Code signing — Apple Developer Program ($99/yr) + macOS notarization + Windows Authenticode. Time-boxed to Sept 1, 2026 per Homebrew Gatekeeper enforcement (Risk R1).
- AUR channel (
amethyst-desktop-bin) — separate follow-up PR once account ownership decided - Scoop channel — separate follow-up PR once bucket strategy decided
- In-app "check for update" banner — poll GH Releases API; modest scope
- Sparkle / Squirrel auto-update — requires signing
- Flathub — sandboxed Linux app center
- Mac App Store / MS Store — walled gardens
- Chocolatey — redundant with Winget/Scoop
- Linux ARM64 + Windows ARM64 assets —
ubuntu-24.04-arm/windows-11-armrunners available; add on demand .desktopMIME handler fornostr:URIs — cheap Linux-integration add- Sigstore/cosign signing — supply-chain hardening (Risk R6)
- SLSA build provenance attestation —
actions/attest-build-provenance(security P2.1) - SBOM generation — CycloneDX/SPDX per release (security P2.3)
- Weekly channel integrity cron — detect package-mgr manifest drift (security P2.4)
- ScoopInstaller/Extras PR (if starting with own bucket) — discoverability boost
- 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.shas 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/linuxdeployfetched 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-Archivein CI). - Dropped separate
verify-versionjob (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(matchescreateReleaseDistributabledependency). - Renamed
HOMEBREW_PAT/WINGET_PAT→HOMEBREW_TOKEN/WINGET_TOKEN(matches existingSONATYPE_PASSWORDpattern). - Asset naming extracted to
scripts/asset-name.shsingle source. - Bump workflow
assert-stable-releasecomposite action deduplicates prerelease re-check across workflows.
External research
- AppImage + Compose Desktop: use
linuxdeploy(not rawappimagetool) for JVM+VLC library bundling. Build on Ubuntu 22.04+ (glibc 2.35);linuxdeployhandles 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] appconsumed via rootallprojects { version = libs.versions.app.get() }so subprojects inheritproject.version. Avoids multi-module resolution drift. - VLC arm64 macOS: resolved — false alarm.
ir.mahozad.vlc-setup:0.1.0fetchesvlc-3.0.21-universal.dmg(85MB, 2-arch) perVlcDownloadTask.ktsource. Bundledlibvlc.dylib/libvlccore.dylibin this repo verified asMach-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 - 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
appimagetoolwrappingcreateDistributable(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
TargetFormatbroken (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-quarantinedeprecation: 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
VLC arm64 macOS verification— RESOLVED (R2 false alarm; plugin fetches universal DMG; bundled dylibs already arm64+x86_64 multi-arch).debMaintaineremail — what contact email should appear in .deb metadata?- AppImage icon scaling — OK to scale existing 100×100
icon.pngto 512×512 via ImageMagick, or commission a proper 512×512? - Dry-run workflow dispatch — include
workflow_dispatch+dry_run: trueinput in this PR? Strongly recommended by deployment verification agent.
Can resolve during implementation
- Secret rotation owner — who owns 90-day rotation of
HOMEBREW_TOKEN,WINGET_TOKEN? (Calendar reminder, runbook owner) - 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)
- 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)
- AUR account ownership — blocks AUR bootstrap entirely (brainstorm: Open Q1)
- Scoop bucket strategy — own bucket vs Extras (brainstorm: Open Q2)