The linux-portable release leg now wraps the createReleaseDistributable tree it already builds into a single-file Flatpak bundle and attaches it to the GH Release as amethyst-desktop-<version>-linux-x64.flatpak. Packaging fixes to the existing (previously unwired) Flathub manifest: - add the missing 512x512 icon (copy of desktopApp icon.png) and install it under hicolor/512x512 instead of the never-present 256px path - drop the openjdk module + sdk-extension: the jpackage tree bundles its own trimmed JRE, so /app/jre was pure bloat - grant --socket=x11 instead of wayland/fallback-x11: Compose Desktop renders via AWT/skiko (X11-only on Linux, XWayland on Wayland), so fallback-x11 left the app socketless on Wayland sessions CI wiring: - install flatpak tooling + the freedesktop runtime/sdk (version greped from the manifest so the pin can't drift), retried like other fetches - inject the AppStream <release> entry for the tagged version at build time (the checked-in metainfo deliberately carries none) - flatpak-builder with --disable-rofiles-fuse (GH runners) and a --state-dir under desktopApp/build so the repo tree stays clean - collect via the existing asset-name.sh contract (new flatpak ext) Verified end-to-end locally: built the bundle from the manifest with a stubbed jpackage tree, installed it, and ran the exported command inside the sandbox (freedesktop 24.08, args forwarded through the wrapper). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4
32 KiB
Building Amethyst Desktop
This guide has everything any fork needs to build Amethyst from source and cut its own release: prerequisites, build commands, the CI release pipeline, the secrets it needs, the distribution channels, and one-time bootstrap steps.
Amethyst maintainers: the account-specific checklist for shipping the official build (Play Console upload, Zapstore
zsp publishwith our nsec, secret ownership) lives inRELEASE_OPS.md. This file stays fork-generic.
- Prerequisites
- Clone + first build
- Generated & vendored artifacts
- Per-format build commands
- Asset naming contract
- Release runbook
- Secrets the CI needs
- Distribution channels
- Bootstrap runbook (one-time)
- Troubleshooting installs
- Uninstall + state paths
- Incident response
- 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+fakerootfor.rpm;appimagetool+desktop-file-utilsfor AppImage;flatpak+flatpak-builderfor the Flatpak bundle (seedesktopApp/packaging/flatpak/README.md)
Install Linux RPM tooling:
# Debian/Ubuntu
sudo apt-get install -y rpm fakeroot
# Fedora
sudo dnf install -y rpm-build
Install appimagetool locally (CI fetches its own — SHA-verified):
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
sudo apt-get install -y desktop-file-utils
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
Clone + first build
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
Generated & vendored artifacts
Two build inputs are generated by tools but committed to the repo, so a normal build or release does not run either — Gradle just consumes the checked-in output. You only regenerate them under the specific conditions below, and each has its own guide:
| Artifact | Committed at | Regenerate when | Guide |
|---|---|---|---|
| Material Symbols subset font | commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf |
You add/remove a MaterialSymbol("\uXXXX") codepoint in MaterialSymbols.kt, or bump the upstream font |
tools/material-symbols-subset/README.md — run ./tools/material-symbols-subset/subset.sh |
| Arti (Tor) native libs | amethyst/src/main/jniLibs/*.so |
You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | tools/arti-build/README.md |
Material Symbols is mandatory after icon changes. The bundled font is a ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at runtime. Regenerate and commit the
.ttfalongside theMaterialSymbols.ktchange. Reusing an existing codepoint needs no regeneration.
Both tools have their own prerequisites (fonttools/brotli for the font; a
Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they
are not required to build Amethyst from the committed sources.
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 |
| Linux Flatpak | flatpak-builder over createReleaseDistributable output — see desktopApp/packaging/flatpak/README.md |
desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak (CI) |
Windows .zip portable |
See below (inline 7z) |
— |
Linux .tar.gz portable |
See below (inline tar) |
— |
Inline portable archives (run after createReleaseDistributable):
./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-<version>-<family>-<arch>.<ext>
Where:
| Field | Values |
|---|---|
<version> |
Tag stripped of leading vX.YY.ZZ |
<family> |
macos, windows, linux |
<arch> |
x64, arm64 |
<ext> |
dmg, msi, zip, deb, rpm, AppImage, flatpak, tar.gz |
Single source of truth: 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.12.1-macos-x64.dmgamethyst-desktop-1.12.1-macos-arm64.dmgamethyst-desktop-1.12.1-windows-x64.msiamethyst-desktop-1.12.1-linux-x64.AppImageamethyst-desktop-1.12.1-linux-x64.flatpak
Reproducible Android builds
The release APKs are bit-for-bit reproducible: anyone can rebuild the exact bytes we ship (minus the signature) from the tagged source and confirm the artifact on F-Droid / Zapstore / GitHub was built from this code and nothing else. What makes that hold:
- Pinned toolchain. AGP, Kotlin, R8, and the Compose compiler are pinned in
gradle/libs.versions.toml; the build targets JDK 21. R8 is deterministic for a fixed version + inputs, so the minified output is stable. Build with the same JDK 21 you see inBUILDING.md/ CI. - No build-time clock. Nothing injects
System.currentTimeMillis()/ build dates intoBuildConfig(a Spotless rule bans the call inquartzandcommons), and AGP normalizes ZIP entry timestamps, so two builds an hour apart are identical. - Deterministic version name.
generateVersionNameonly appends a branch suffix off feature branches; a release tag builds in detached-HEAD(or from a source tarball with no.git) resolve to the bareappversion. - No dependency-metadata blob.
dependenciesInfo { includeInApk = false; includeInBundle = false }inamethyst/build.gradle.ktsstops AGP from embedding the Google-encrypted dependency protobuf in the signing block — that ciphertext is non-deterministic. - Reproducible native library. The bundled Tor (Arti)
.sois the one binary we compile ourselves; it is built reproducibly from source (pinned Rust toolchain, locked deps, canonical build path). Seetools/arti-build/README.md→ "Reproducible builds". All other native libs (secp256k1,webrtc) are version-pinned Maven prebuilts and so are byte-identical by download.
Verify a release APK reproduces
# 1. Check out the exact released tag and build the same variant unsigned.
git checkout v1.12.1
./gradlew clean :amethyst:assembleFdroidRelease
# 2. Diff your unsigned build against the published APK, ignoring only the
# signature (META-INF/*). apksigner + a zip-aware diff is the simplest check;
# diffoscope gives a human-readable breakdown of any remaining delta.
diffoscope \
amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned.apk \
amethyst-fdroid-arm64-v8a-1.12.1.apk
A clean run shows differences confined to META-INF/ (the signing files). Any
diff in classes*.dex, resources.arsc, or native libs means something in the
toolchain drifted — file it before publishing.
Local SonarQube analysis (opt-in)
The build supports running a SonarQube analysis against a locally hosted server. It is off by default: unless you opt in, the scanner plugin is neither downloaded nor applied and the build is unaffected.
1. Install and start a local SonarQube server
Either run the official Docker image:
docker run -d --name sonarqube -p 9000:9000 sonarqube:community
or download the Community Build zip,
unzip it, and start it (requires a JDK 17+ on PATH):
cd sonarqube-<version>
bin/macosx-universal-64/sonar.sh console # pick the folder matching your OS
Once it reports up, open http://localhost:9000 (first login admin/admin,
you'll be asked to change it), create a local project named Amethyst with
project key Amethyst, and generate a project analysis token for it
(Project Settings → Analysis Method → With Gradle, or
My Account → Security → Generate token). The token looks like sqp_….
2. Point the build at your server
Add the server and token to local.properties (gitignored — the token never
lands in the repo):
sonar.host.url=http://localhost:9000
sonar.token=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
3. Run the analysis
./gradlew sonar
When it finishes, browse the results at http://localhost:9000/dashboard?id=Amethyst.
4. Optional: include Android Lint results
The scanner auto-imports each Android module's lint report and shows the
findings as external issues alongside Sonar's own. It only imports — it never
runs lint itself — so without the reports on disk the analysis warns
Unable to import Android Lint report file(s). Generate them first, then run
the scan as a separate invocation (chaining lint and sonar in one Gradle
call does not guarantee lint finishes first):
./gradlew :amethyst:lintPlayDebug :benchmark:lintBenchmark :nappletHost:lintDebug
./gradlew sonar
The reports persist under each module's build/reports/, so re-run lint only
when you want fresh lint data in the next scan.
Every sonar.* entry in local.properties is forwarded to the scanner, so any
analysis parameter
can be set there. sonar.projectKey / sonar.projectName default to the root
project name (Amethyst).
Even when opted in, the scanner plugin only loads on invocations that actually
request the sonar task — ordinary builds and IDE syncs are unaffected (which
is also why ./gradlew tasks doesn't list it).
Note: the SonarQube Gradle scanner plugin is LGPL-3.0. It is a build-time-only tool fetched after explicit opt-in; it is never linked into shipped artifacts.
Release runbook
The release flow is driven by a tag push. Every cut ships Android + Desktop + Quartz library in one pipeline.
-
Bump the app version and Android
versionCodeingradle/libs.versions.toml(appCodeis a monotonic integer — it must increment even whenappis unchanged):[versions] app = "1.08.1" # new semver appCode = "449" # Android versionCodeamethyst/build.gradle.ktsreads both from the catalog (versionCode = libs.versions.appCode.get().toInt()), so there is nothing else to edit. -
Commit + tag + push:
git commit -am "chore(release): 1.12.1" git tag -s v1.12.1 -m "Release 1.12.1" git push && git push --tags -
Wait for the
Create Release Assetsworkflow to finish (~25–30 min). -
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
-
Stable vs prerelease — a tag containing
-rc,-beta,-alpha,-dev, or-snapshotis 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:
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.
Secrets the CI needs
The Create Release Assets workflow reads these from GitHub repo secrets. A
fork must provide its own values — none are inherited. (GITHUB_TOKEN is
provided automatically; everything else you set yourself.)
| Secret | What it is | Used for |
|---|---|---|
SIGNING_KEY |
Base64 of your Android keystore (.jks/.keystore) |
Signs the Play + F-Droid AAB and APK |
KEY_ALIAS |
Keystore key alias | Same Android signing step |
KEY_STORE_PASSWORD |
Keystore password | Same |
KEY_PASSWORD |
Key password | Same |
SONATYPE_USERNAME |
Maven Central (Sonatype) user token name | Publishing the quartz library |
SONATYPE_PASSWORD |
Maven Central user token password | Same |
SIGNING_PRIVATE_KEY |
GPG/PGP private key, ASCII-armored | Signs the Maven artifacts (Central requires it) |
SIGNING_PASSWORD |
Passphrase for that GPG key | Same |
MAC_CERTIFICATE_P12 |
Base64 of your Apple Developer ID Application cert (.p12, includes the private key) |
Signs the macOS desktop DMG and the macOS amy jlink tarball |
MAC_CERTIFICATE_PASSWORD |
Password set when exporting the .p12 |
Imports the cert into the CI keychain |
MAC_SIGN_IDENTITY |
Full identity string, e.g. Developer ID Application: Your Name (TEAMID) |
The codesign identity to sign with |
MAC_NOTARY_APPLE_ID |
Apple ID email of the notarization account | Apple notarization (notarytool) |
MAC_NOTARY_PASSWORD |
App-specific password for that Apple ID (not the login password) | Same |
MAC_NOTARY_TEAM_ID |
10-char Apple Developer Team ID | Same |
HOMEBREW_TOKEN |
PAT for Homebrew/homebrew-cask |
Desktop cask bump (stable tags) |
WINGET_TOKEN |
PAT for microsoft/winget-pkgs |
Desktop winget bump (stable tags) |
CROWDIN_PERSONAL_TOKEN, CROWDIN_PROJECT_ID |
Crowdin API creds | Translation sync (separate workflow, not the release) |
Note the three distinct signing identities people often conflate:
SIGNING_KEY + KEY_* is the Android keystore; SIGNING_PRIVATE_KEY +
SIGNING_PASSWORD is the GPG key for Maven Central; MAC_CERTIFICATE_* +
MAC_SIGN_IDENTITY + MAC_NOTARY_* is the Apple Developer ID for the macOS
desktop DMG. They are unrelated — each comes from a different authority.
The macOS signing secrets are optional: if MAC_CERTIFICATE_P12 is unset
the release workflow still builds the DMG and the macOS amy tarball, just
unsigned (the previous behavior). Provision all six to switch signing +
notarization on for both. Obtaining them requires Apple Developer Program
membership ($99/yr). The same one certificate signs both artifacts.
The macOS amy tarball is the jlink image (bundled JRE), so signing it means
codesigning every Mach-O binary in that runtime with hardened-runtime
entitlements (cli/packaging/macos/amy.entitlements — needed so the JVM can
load the secp256k1 native library it extracts at runtime). A loose .tar.gz
cannot be stapled (Apple's stapler only handles .app/.dmg/.pkg), so
Gatekeeper verifies notarization online on first run — fine for a CLI.
Note the Homebrew-core jvm bundle (amy-<version>-jvm.tar.gz) is not signed:
Homebrew removes the quarantine attribute on its own downloads.
Validated (Developer ID
D77MCV9NZ7): signing every Mach-O in the bundled JRE with hardened runtime +amy.entitlementsletsamy initderive a key via secp256k1 with no library-validation crash. Droppingdisable-library-validationreproducesUnsatisfiedLinkError: … different Team IDson the runtime-extractedlibsecp256k1-jni.dylib— so that entitlement is load-bearing, not decorative.Open risk — embedded jar natives. The notary service unpacks
lib/*.jarrecursively and checks every Mach-O for a signature + hardened runtime. Our sign loop only touches loose files, so 9 unsigned natives ride along inside jars on a macOS build:secp256k1(1, required at runtime),jna(2),sqlite(2), andskiko(4, dead weight — Compose UI the CLI never renders). WhethernotarytoolreturnsAcceptedorInvalidon these is unverified (the local validation had no notary creds). Decide it with one run: set the sixMAC_*secrets and triggercreate-release.ymlviaworkflow_dispatchwithdry_run=true— the sign+notarize step runs regardless ofdry_runand now prints the per-file notary log on a non-Acceptedverdict. If it comes backInvalid, the fix is to codesign the dylibs inside those jars before zipping (and/or strip the unusedskiko/Compose jars from the CLI image — the:commonscore/ui split the size budget already flags). The desktop app bundles the same jars through Compose/jpackage notarization, so run a desktop dry-run too; its in-jar handling differs and is likewise unverified.
Generating the values:
# Android keystore → base64 for SIGNING_KEY (one line, no wrapping)
keytool -genkey -v -keystore upload.jks -keyalg RSA -keysize 2048 \
-validity 10000 -alias upload # creates the keystore (once)
base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY
# GPG key → armored private key for SIGNING_PRIVATE_KEY
gpg --full-generate-key # create the key (once)
gpg --armor --export-secret-keys <KEY_ID> # paste output into SIGNING_PRIVATE_KEY
# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12.
# In Keychain Access, export the "Developer ID Application: ..." cert (with its
# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD).
base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12
security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string
# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com
# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login.
SONATYPE_USERNAME/SONATYPE_PASSWORD are a user token from
https://central.sonatype.com (Account → Generate User Token), not your login.
A fork that doesn't publish a library can drop the Publish Quartz Lib step and
the four Sonatype/GPG secrets.
Distribution channels
One v* tag fans out to several channels. Which apply depends on where a fork
distributes; the official Amethyst rollout for each is in
RELEASE_OPS.md.
| Channel | How it ships | Push or pull |
|---|---|---|
| GitHub Releases | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
| Maven Central | Same workflow runs publishAllPublicationsToMavenCentral for quartz |
Automatic (CI) |
| Google Play | Download the signed amethyst-googleplay-<version>.aab from the GH Release and upload it in Play Console |
Manual push |
| F-Droid | F-Droid's build server detects the new tag and builds the fdroid flavor from source per its recipe in the external fdroiddata repo, then signs + publishes itself |
Pull (build-from-source) |
| Zapstore | The zsp CLI reads zapstore.yaml and publishes a Nostr software-release event signed with the app's nsec |
Manual push (Nostr) |
| Homebrew + Winget | bump-homebrew.yml / bump-winget.yml open version-bump PRs on stable tags |
Automatic (CI) |
Two channels need the build to stay split into product flavors (see
amethyst/build.gradle.kts → productFlavors):
playcarries Firebase / Google Play Services (push notifications, ML Kit, etc.) → the Google Play AAB.fdroidswaps those for UnifiedPush and no-op/open-source implementations (amethyst/src/fdroid/…) so the build is free of proprietary dependencies → what F-Droid builds and what Zapstore distributes.
F-Droid is pull, not push. We never upload to F-Droid; its server builds our
tagged source. Keeping the fdroid flavor proprietary-free and the
fastlane/metadata/android/ descriptions current is all that's required. F-Droid
reads an optional per-release changelog from
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt.
Bootstrap runbook (one-time)
Secrets to provision in GitHub repo settings
The full secret inventory is in § Secrets the CI needs. The two that need the most setup care are the package-manager PATs, because of their token type and scope:
| 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) |
Rotate both on a 90-day cadence. Owner: assigned via 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)
brew bump-cask-pr amethyst-nostr \
--version 1.12.1 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-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.
The desktop app is already on mainline Homebrew.
homebrew/caskis the mainline cask repo — GUI apps live in homebrew-cask, CLIs in homebrew-core; both are "mainline." A private tap is only the fallback if Homebrew ever rejects the (now signed + notarized) cask.
Homebrew-core formula for the amy CLI (one-time initial PR)
The CLI goes to homebrew-core (mainline formulae), not homebrew-cask —
casks are for GUI apps. homebrew-core builds in a network-sandboxed
environment, so a from-source Gradle build can't resolve its Maven
dependencies there. Instead the formula downloads the pre-built no-JRE jar
bundle amy-<version>-jvm.tar.gz (published by create-release.yml) and
depends_on "openjdk". The reference formula lives at
cli/packaging/homebrew/amy.rb.
To submit:
# 1. Grab the published asset's sha256
curl -fsSL -o amy-jvm.tar.gz \
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
shasum -a 256 amy-jvm.tar.gz
# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR
brew create --set-name amy --tap homebrew/core \
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
# (paste the reference formula body, run `brew audit --new amy`,
# `brew install --build-from-source amy`, `brew test amy`, then PR it.)
Caveats that the maintainer must weigh before submitting:
- Name collision.
amymay already exist in homebrew-core — check withbrew search amyfirst. If taken, fall back toamethyst-cli. - Pre-built-jar scrutiny. homebrew-core prefers source builds; downloading a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to justify it (sandboxed Gradle can't fetch Maven deps).
- Bundle size. The bundle is ~70 MB today because
:commonsleaks Compose/Skiko jars onto the CLI classpath. Trimming that (a:commonscore/ui split) would shrink it and smooth review — tracked as a follow-up.
After the formula merges, the livecheck block lets homebrew-core's BrewTestBot
auto-open version-bump PRs on each stable release — no token or workflow on our
side (unlike the cask/winget bumps).
Winget (one-time initial submission)
wingetcreate new \
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-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:
- Right-click → Open on the app (don't double-click) — then click Open on the Gatekeeper dialog
- Or:
xattr -cr /Applications/Amethyst.appto strip quarantine - 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:
- Click More info on the SmartScreen dialog
- 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
chmod +x Amethyst-*.AppImage
./Amethyst-*.AppImage
On Fedora Silverblue / very minimal distros, FUSE might be missing. Use
--appimage-extract-and-run:
./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.
Exception: Flatpak. The sandbox redirects XDG dirs into
~/.var/app/com.vitorpamplona.amethyst.Desktop/, so a Flatpak install keeps
its own separate state and does not see (or risk downgrading) state written
by any other channel.
| 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 |
| Linux (Flatpak) | /var/lib/flatpak or ~/.local/share/flatpak |
~/.var/app/com.vitorpamplona.amethyst.Desktop/ |
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
- Flatpak:
flatpak uninstall com.vitorpamplona.amethyst.Desktop(add--delete-datato also remove~/.var/app/…) - macOS
.dmg: drag from/Applicationsto Trash, then delete state dirs manually
Incident response
Bad GH Release asset
- Immediately mark release as prerelease (pauses bump workflows):
gh release edit v1.08.1 --prerelease - Delete the bad asset:
gh release delete-asset v1.08.1 amethyst-desktop-1.08.1-macos-arm64.dmg --yes - Rebuild locally or rerun the failing matrix job:
gh run rerun <run-id> --failed - Flip back to stable once verified (re-fires bump workflows — confirm fix first):
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:
- Ship a point release (preferred — users upgrade via
winget upgrade) - File a manifest-removal PR against
microsoft/winget-pkgs. Moderator review: 24–72h.
User-facing communication
On any incident:
- Edit the release body on GitHub with a warning banner + workaround
- Pin a GH Issue with downgrade instructions per channel
- Announce via Nostr relay + project social channels
Fallback plans
macOS Intel runner retirement
GitHub's macos-13 runner will eventually be deprecated. Monitor
https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
for the deprecation date. When it hits:
- Drop the
macos-13matrix entry from.github/workflows/create-release.yml - Add a cross-arch build step on
macos-14using a bundled x64 JDK +jpackage --mac-signing-prefixshenanigans, OR accept that only Apple Silicon DMGs ship and direct Intel users towingeton a Parallels VM or to rebuild from source. - 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 (wiring done — needs Apple creds): The signing { sign.set(true) }
notarization {}blocks are already indesktopApp/build.gradle.kts(gated on theAMETHYST_MAC_SIGN_IDENTITYenv var), and the macOS leg ofcreate-release.ymlimports a Developer ID cert into a throwaway keychain and exports the signing/notary env. It all stays a no-op until the sixMAC_*/notary secrets are provisioned (see § Secrets the CI needs) — until then the DMG builds unsigned. To turn it on: join the Apple Developer Program ($99/yr), create a Developer ID Application certificate, generate an app-specific password, and set the six secrets. The first signed+notarized DMG is best validated with aworkflow_dispatchdry-run before a real tag.
Option B: Pivot to a private Homebrew tap:
# 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)