# 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`. Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner and produce the portable `.zip` only — that image ships no WiX, so CI cannot package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries themselves are x86 and run under emulation). - **Linux (all)**: run Gradle on the **JetBrains Runtime 21** (the `jbrsdk` build: `actions/setup-java` with `distribution: jetbrains`, or `JAVA_HOME=/path/to/jbrsdk-21`). jpackage bundles the JDK Gradle runs on, and only JBR's AWT can draw natively on Wayland; a package built on Temurin runs on X11/XWayland only (see [Wayland and X11 on Linux](#wayland-and-x11-on-linux)). 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 # createReleaseAppImage picks appimagetool-.AppImage matching the JVM's # os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM). ARCH="$(uname -m)" curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \ "https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage" chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.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 Three build inputs are **generated by tools but committed to the repo**, so a normal build or release does **not** run any of them — 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** | `commonsUI/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) | | **zxing-cpp (QR decoder) native libs** | `amethyst/src/main/jniLibs/*/libzxingcpp_android.so` | You bump `ZXING_CPP_VERSION`, change the JNI wrapper, or want to reproduce the binaries | [`tools/zxing-cpp-build/README.md`](tools/zxing-cpp-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. Each tool has its own prerequisites (`fonttools`/`brotli` for the font; a Rust toolchain for Arti; `cmake` + `ninja` for zxing-cpp; and, for both native builds, the exact Android NDK revision pinned in `tools/arti-build/ANDROID_NDK_VERSION`) documented in their READMEs — none of them are required to build Amethyst from the committed sources. That NDK pin is a single file for the whole repo, not a copy per tool: `build-arti.sh`, `build-zxingcpp.sh` and `:amethyst`'s `ndkVersion` all read it, so bumping it moves every native build and the packaging toolchain together and they cannot drift apart. (It lives under `tools/arti-build/` for history; it is not Arti's alone.) The NDK half of that pin reaches the ordinary Android build. AGP strips every native library it packages with the NDK's `llvm-strip`, so `:amethyst` sets `ndkVersion` from `ANDROID_NDK_VERSION` — one revision for the libraries we build and the ones we merge from dependencies. Both of our own libraries are then excluded from that strip step (`packaging.jniLibs.keepDebugSymbols`): they are already stripped by the pinned toolchain, so skipping the pass costs no size and lets the `.so` inside an APK be compared byte-for-byte against the committed, independently reproducible one. The Rust toolchain stays irrelevant either way; Studio/AGP fetches the pinned NDK on demand, or pre-install it with `sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"`. > **Every ABI split needs its own copy of each library.** The APK is split four > ways (`arm64-v8a`, `x86_64`, `armeabi-v7a`, `x86`). A split missing > `libarti_android.so` installs and runs with Tor silently unavailable; one > missing `libzxingcpp_android.so` installs with the QR scanner broken. The > `verifyNativeAbis` Gradle task fails the build if a library's ABI list drifts > from the split list, and names the `build-arti.sh --target=…` / > `build-zxingcpp.sh --abi …` to run — and rejects a file that is not an ELF > built for that architecture, which a missing-file check would pass. --- ## 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-*-.AppImage` (x86_64 or aarch64, from host) | | Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-.flatpak` (CI; x86_64 or aarch64) | | 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` | The AppImage is the exception: `amethyst-desktop--.AppImage`. AppImageHub flags `linux` in an AppImage name (every AppImage is for Linux) and expects the AppImage arch names. 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-x86_64.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** — the GH Release should hold **49 assets**: - **14 desktop**, one per matrix leg × format: - macOS arm64: `dmg` (1) - Windows x64: `msi` + portable `zip` (2) - Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below - Linux x64 / arm64: `deb` + `rpm` (4) - Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6) There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile and no Intel runner leg is configured, so macOS ships arm64-only. There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why the x64 leg gets an MSI). Revisit if that image gains WiX, or if jpackage learns the WiX 4+ `wix build` CLI. - **15 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid `.apks` set built for Accrescent + **2 R8 mapping files** (`amethyst-{googleplay,fdroid}-mapping-.txt.gz`). The mappings are not optional extras: the release build is minified, so without them no crash report from an APK/`.apks` user can be read. See [`RELEASE_OPS.md` § Crash reports](RELEASE_OPS.md#7-crash-reports--retrace). - **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64), `deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the one arch-independent no-JRE `amy--jvm.tar.gz` for Homebrew-core. - **10 geode** — same shape as amy. - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - Android flow unchanged Quick diff against the previous release, which catches a silently-dropped matrix leg better than any count: ```bash diff <(gh release view v1.13.0 --json assets --jq '.assets[].name' | sed 's/1\.13\.0/VER/g' | sort) \ <(gh release view v1.13.1 --json assets --jq '.assets[].name' | sed 's/1\.13\.1/VER/g' | sort) ``` 5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`, or `-snapshot` is auto-classified as prerelease. Only stable tags run the Homebrew + Winget bump workflows (and those are no-ops until the one-time bootstrap PRs land — see § Bootstrap). ### 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`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — | | ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — | | `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 (the unused `skiko`/Compose jars left the CLI image with the > `:commons` / `:commonsUI` split). 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` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | 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 — **currently no-ops**: neither package has been bootstrapped upstream yet (§ Bootstrap) | Automatic (CI), inactive | 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) > **Status as of v1.17.0:** the cask, the `amy` formula and winget are all live > upstream. The `amethyst-nostr` cask (`Homebrew/homebrew-cask`) and the `amy` > formula (`Homebrew/homebrew-core`) are both on Homebrew's **autobump** list, so > BrewTestBot opens their version PRs itself. Winget's initial submission, > [microsoft/winget-pkgs#422752](https://github.com/microsoft/winget-pkgs/pull/422752), > merged on 2026-09-23. Only the `geode-relay` formula has never been submitted; > its sync workflow detects the absence and skips with a `::warning::`. ### Package-manager credentials (and why there are none) The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs). Neither package-manager channel adds anything to it: **There are deliberately no package-manager PATs in CI.** The cask is on Homebrew's autobump list, so BrewTestBot opens its version PR and nothing of ours pushes to `homebrew-cask` at all; the Winget bump runs on a maintainer's machine with `gh` auth. The reasoning below still matters, because it is what the cask script falls back to if Homebrew ever takes the cask off autobump, and it is why this repo has no third secret to rotate: `brew bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's account** (`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that fork, then opens the PR upstream. That shape forces a **classic** PAT with the `repo` scope: - A fine-grained PAT cannot express it. Its "Repository access" selector only lists repos owned by the resource owner, so `Homebrew/homebrew-cask` can never be selected — and Homebrew's API layer authorises against classic OAuth scopes (`x-oauth-scopes`), which fine-grained tokens do not emit. - Homebrew declares the requirement in source as `CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`). And `repo` cannot be narrowed: it grants write to *every* repository the owning account can reach — including `vitorpamplona/amethyst` itself. Stored as an Actions secret it would be usable by **anyone with push access to this repo**, since a pushed branch containing a workflow runs with repo secrets. That is a strict escalation for a channel that ships one DMG a month. So the split is: - **CI** (`bump-homebrew.yml`, `GITHUB_TOKEN` only) does the error-prone bookkeeping: downloads the DMG, asserts it is notarized + stapled, computes the sha256, and opens an in-repo PR syncing `desktopApp/packaging/homebrew/amethyst-nostr.rb`. - **BrewTestBot** opens the `homebrew-cask` version PR, because the cask is autobumped (`autobump: true` in `formulae.brew.sh/api/cask/amethyst-nostr.json`). `brew bump-cask-pr` refuses to open a competing PR for an autobumped cask. - **A maintainer** merges the sync PR and runs `scripts/bump-homebrew-cask.sh`. BrewTestBot computes its own sha256 and never checks notarization, so the script re-verifies the live DMG's sha256 and stapled ticket, finds the bot's PR, and fails if the sha256 in it differs from ours. No token is needed: ```bash scripts/bump-homebrew-cask.sh v1.18.0 # verify only; re-run if the PR is not open yet ``` Only if the cask is ever taken off autobump does the script call `brew bump-cask-pr` itself, and then the token lives only in that maintainer's shell: ```bash export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope scripts/bump-homebrew-cask.sh v1.18.0 ``` Create one at . Prefer a dedicated bot account whose only asset is a fork of `homebrew-cask`, so a leak reaches nothing else. ### Winget Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives `gh`, which a maintainer is already authenticated with, and it does not need `wingetcreate` (Windows-only) because winget manifests are plain YAML — so it runs fine from macOS or Linux: ```bash scripts/bump-winget.sh v1.18.0 ``` CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the MSI, computes the sha256, reads the `ProductCode` out of the MSI Property table with `msitools`, and opens an in-repo PR syncing `desktopApp/packaging/winget/*.yaml`. The script re-verifies the sha256 against the live asset, then forks `microsoft/winget-pkgs`, commits the three manifests to `manifests/v/VitorPamplona/Amethyst//`, and opens the PR. The previous design stored a classic `public_repo` PAT as `WINGET_TOKEN` and passed it to the third-party `vedantmgoyal9/winget-releaser` action — a token with write access to every public repo the account owns, handed to code we do not control, in a place any push-access collaborator could read it from. None of that is needed. ### Homebrew cask (one-time initial PR) > `brew bump-cask-pr` **cannot** do this step. It *updates* an existing cask — > against a name that isn't in the tap yet it fails outright: > `Error: Cask 'amethyst-nostr' is unavailable: No Cask with this name exists.` > The first submission is a **new-cask** PR, which is a different flow: ```bash # 1. Scaffold from the published DMG (macOS arm64 — there is no Intel DMG) brew create --cask \ https://github.com/vitorpamplona/amethyst/releases/download/v1.14.0/amethyst-desktop-1.14.0-macos-arm64.dmg \ --set-name amethyst-nostr # 2. Fill in the cask body, then audit as a NEW cask (stricter than a bump) brew audit --new --cask amethyst-nostr brew install --cask amethyst-nostr # verify it actually installs brew uninstall --cask amethyst-nostr # 3. Open the PR against Homebrew/homebrew-cask by hand ``` The DMG **must be notarized and stapled** or Homebrew will reject it; verify with `spctl -a -t open --context context:primary-signature -v ` before submitting. 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 — *that* is where `brew bump-cask-pr` applies. > **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 used to be ~70 MB because `:commons` leaked Compose/Skiko jars onto the CLI classpath. Compose UI now lives in `:commonsUI`, which `:cli` does not depend on: the JVM tarball is ~55 MB and the jlink image tarball ~80 MB (1.16.0, Linux x64). The release workflow caps every amy asset at 120 MB. 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 ``` --- ## Wayland and X11 on Linux Every Linux package (`.deb`, `.rpm`, AppImage, tar.gz, Flatpak) draws natively on Wayland, with no XWayland, and through X11 on an X11 session. Two pieces make that work: - **The runtime.** The packages bundle the JetBrains Runtime instead of Temurin: stock OpenJDK's AWT only speaks X11, while JBR's has a Wayland toolkit (`WLToolkit`). Release CI builds the Linux legs on it and fails a build whose runtime lacks `lib/libawt_wlawt.so`. - **The window.** On a Wayland session (`$WAYLAND_DISPLAY` names a live socket) the app starts AWT on `WLToolkit` before anything else (`desktopApp/.../platform/WaylandDisplay.kt`). Compose's own window can't draw there (Skiko's window surface needs an X11 handle), so the windows are Swing frames with a Compose panel that draws through Swing, with Skiko's CPU renderer (`AppWindow.kt`). On X11, macOS and Windows nothing changes. The title bar on Wayland: where the compositor draws window decorations (KDE, sway, labwc, Hyprland, COSMIC, niri) the app uses them, so it gets the same title bar as every other app there. GNOME draws none, so JBR draws a GTK title bar, which follows the desktop's light/dark setting. That is deliberate: JBR has no API to draw into the title bar on Wayland (as the app does on macOS), and an app-drawn bar would lose GNOME's window menu and button layout. `-Dsun.awt.wl.WindowDecorationStyle=builtin|gtk|server` overrides the choice. Set `AMETHYST_X11=1` to keep a Wayland session on XWayland (the Flatpak also needs `--socket=x11` for that). `./gradlew :desktopApp:run` on a stock JDK runs on X11/XWayland; run it on JBR to try the Wayland path. `scripts/wayland-smoke-test.sh ` starts a packaged app on a headless sway with Xwayland turned off and passes only if it opens a native Wayland window (needs `sway`). --- ## Video and audio on Linux The desktop player decodes through the system's GStreamer. The released `.deb` and `.rpm` depend on the plugins it needs (base and good, plus the H.264/AAC decoders as a recommended package), so the package manager installs them. The `.rpm` gets them from `packageRpm` itself; the `.deb` from `scripts/add-deb-gstreamer-deps.sh`, which CI runs after `packageDeb`, so run it yourself on a `.deb` you build locally. The AppImage, the tar.gz and `./gradlew :desktopApp:run` cannot declare dependencies: without the plugins, every video shows "Can't play this video — Failed to create native player". Install them with: ```bash # Debian / Ubuntu sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav # Fedora (its libav plugin is built on ffmpeg-free, which can lack H.264; openh264 covers it) sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-plugin-libav gstreamer1-plugin-openh264 # openSUSE sudo zypper install gstreamer-plugins-base gstreamer-plugins-good gstreamer-plugins-libav ``` The app reads the plugin list at start, so restart it after installing. --- ## 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.Amethyst/` (the manifest persists `~/.amethyst` and `~/.java` there too), so a Flatpak install keeps its own separate state and does not see (or risk downgrading) state written by any other channel. **Flatpak app ID changes.** The bundle is now `com.vitorpamplona.amethyst.Amethyst`. Each earlier ID installs as a separate app with its own `~/.var/app//`: - **v1.18.0, `com.vitorpamplona.amethyst`.** It kept accounts, drafts and settings in `~/.var/app/com.vitorpamplona.amethyst/`. Without a system keyring, the private keys are there too: the password key file lives under the sandbox's `XDG_DATA_HOME` (`data/amethyst/`). Move the whole directory to the new ID, then remove the old app *without* `--delete-data`: ```bash flatpak kill com.vitorpamplona.amethyst 2>/dev/null flatpak kill com.vitorpamplona.amethyst.Amethyst 2>/dev/null mv -T ~/.var/app/com.vitorpamplona.amethyst ~/.var/app/com.vitorpamplona.amethyst.Amethyst flatpak uninstall com.vitorpamplona.amethyst ``` `mv -T` refuses to merge into a directory that is not empty, instead of nesting the old one inside it. If it fails with "Directory not empty", the new app has already run once. If you have not logged in to it yet, delete its fresh directory (`rm -r ~/.var/app/com.vitorpamplona.amethyst.Amethyst`) and run the `mv` again. Until the old app is removed, the menu shows two "Amethyst" entries: the single-file bundle has no remote, so flatpak cannot replace one ID with the other on its own. - **Up to v1.17.0, `com.vitorpamplona.amethyst.Desktop`** (Flathub forbids IDs ending in `.desktop`). There is no data worth moving: that bundle did not persist `~/.amethyst` or `~/.java`, so it lost its accounts, drafts and settings on every restart, and what it did keep (Tor state, image cache) is rebuilt on demand. Remove it with `flatpak uninstall --delete-data com.vitorpamplona.amethyst.Desktop` and log in again in the new one. | OS | App location | State directories | |---|---|---| | macOS | `/Applications/Amethyst.app` | `~/.amethyst` (accounts + keys)
`~/Library/Application Support/Amethyst` (Tor)
`~/Library/Caches/AmethystDesktop` (image cache)
`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) | | 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.Amethyst/` | **macOS preferences are in a SHARED file.** `DesktopPreferences` uses the Java Preferences API, which on macOS writes into `~/Library/Preferences/com.apple.java.util.prefs.plist` — one plist for *every* Java application on the machine, not a per-app file. Never delete it to "reset Amethyst": that wipes unrelated apps' settings. This is why the Homebrew cask's `zap` stanza deliberately omits it. 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.Amethyst` (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)