# 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 publish` with our nsec, > secret ownership) lives in [`RELEASE_OPS.md`](RELEASE_OPS.md). This > file stays fork-generic. - [Prerequisites](#prerequisites) - [Clone + first build](#clone--first-build) - [Generated & vendored artifacts](#generated--vendored-artifacts) - [Per-format build commands](#per-format-build-commands) - [Asset naming contract](#asset-naming-contract) - [Release runbook](#release-runbook) - [Secrets the CI needs](#secrets-the-ci-needs) - [Distribution channels](#distribution-channels) - [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`; `appimagetool` + `desktop-file-utils` for AppImage; `flatpak` + `flatpak-builder` for the Flatpak bundle (see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md)) Install Linux RPM tooling: ```bash # 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): ```bash # 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 ```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 ``` --- ## 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`](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`](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 `.ttf` alongside the `MaterialSymbols.kt` > change. 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/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`): ```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 `vX.YY.ZZ` | | `` | `macos`, `windows`, `linux` | | `` | `x64`, `arm64` | | `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `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.12.1-macos-x64.dmg` - `amethyst-desktop-1.12.1-macos-arm64.dmg` - `amethyst-desktop-1.12.1-windows-x64.msi` - `amethyst-desktop-1.12.1-linux-x64.AppImage` - `amethyst-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 in `BUILDING.md` / CI. - **No build-time clock.** Nothing injects `System.currentTimeMillis()` / build dates into `BuildConfig` (a Spotless rule bans the call in `quartz` and `commons`), and AGP normalizes ZIP entry timestamps, so two builds an hour apart are identical. - **Deterministic version name.** `generateVersionName` only 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 bare `app` version. - **No dependency-metadata blob.** `dependenciesInfo { includeInApk = false; includeInBundle = false }` in `amethyst/build.gradle.kts` stops AGP from embedding the Google-encrypted dependency protobuf in the signing block — that ciphertext is non-deterministic. - **Reproducible native library.** The bundled Tor (Arti) `.so` is the one binary we compile ourselves; it is built reproducibly from source (pinned Rust toolchain, locked deps, canonical build path). See [`tools/arti-build/README.md`](tools/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 ```bash # 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](https://www.sonarsource.com/products/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: ```bash docker run -d --name sonarqube -p 9000:9000 sonarqube:community ``` or download the [Community Build zip](https://www.sonarsource.com/products/sonarqube/downloads/), unzip it, and start it (requires a JDK 17+ on `PATH`): ```bash cd sonarqube- bin/macosx-universal-64/sonar.sh console # pick the folder matching your OS ``` Once it reports up, open (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): ```properties sonar.host.url=http://localhost:9000 sonar.token=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ### 3. Run the analysis ```bash ./gradlew sonar ``` When it finishes, browse the results at . ### 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): ```bash ./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](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/) 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. 1. **Bump the app version and Android `versionCode`** in `gradle/libs.versions.toml` (`appCode` is a monotonic integer — it must increment even when `app` is unchanged): ```toml [versions] app = "1.08.1" # new semver appCode = "449" # Android versionCode ``` `amethyst/build.gradle.kts` reads both from the catalog (`versionCode = libs.versions.appCode.get().toInt()`), so there is nothing else to edit. 2. **Commit + tag + push**: ```bash git commit -am "chore(release): 1.12.1" git tag -s v1.12.1 -m "Release 1.12.1" git push && git push --tags ``` 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). 4. **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 5. **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. --- ## 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--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.entitlements` lets `amy init` derive a key via > secp256k1 with no library-validation crash. Dropping `disable-library-validation` > reproduces `UnsatisfiedLinkError: … different Team IDs` on the runtime-extracted > `libsecp256k1-jni.dylib` — so that entitlement is load-bearing, not decorative. > > **Open risk — embedded jar natives.** The notary service unpacks `lib/*.jar` > recursively 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), and `skiko` (4, dead weight — Compose UI the CLI never renders). > Whether `notarytool` returns `Accepted` or `Invalid` on these is **unverified** > (the local validation had no notary creds). **Decide it with one run:** set the > six `MAC_*` secrets and trigger `create-release.yml` via `workflow_dispatch` > with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and > now prints the per-file notary log on a non-`Accepted` verdict. If it comes > back `Invalid`, the fix is to codesign the dylibs *inside* those jars before > zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the > `:commons` core/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: ```bash # 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 # 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 (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`](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-.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`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** | | **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](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`): - **`play`** carries Firebase / Google Play Services (push notifications, ML Kit, etc.) → the Google Play AAB. - **`fdroid`** swaps 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/.txt`. --- ## Bootstrap runbook (one-time) ### Secrets to provision in GitHub repo settings The full secret inventory is in [§ Secrets the CI needs](#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) ```bash 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/cask` *is* 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--jvm.tar.gz` (published by `create-release.yml`) and `depends_on "openjdk"`. The reference formula lives at [`cli/packaging/homebrew/amy.rb`](cli/packaging/homebrew/amy.rb). To submit: ```bash # 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.** `amy` may already exist in homebrew-core — check with `brew search amy` first. If taken, fall back to `amethyst-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 `:commons` leaks Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons` core/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) ```bash 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: 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**. **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-data` to also remove `~/.var/app/…`) - 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 (wiring done — needs Apple creds)**: The `signing { sign.set(true) }` + `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of `create-release.yml` imports a Developer ID cert into a throwaway keychain and exports the signing/notary env. It all stays a **no-op until the six `MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI needs](#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 a `workflow_dispatch` dry-run before a real tag. **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)