# 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)**: 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-<arch>.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-*-<arch>.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-*-<arch>.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-<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` |

The AppImage is the exception: `amethyst-desktop-<version>-<x86_64|aarch64>.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-<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):

```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
<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):

```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-<version>.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-<ver>-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-<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.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 <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`](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-<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`](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/<versionCode>.txt`.

---

## Bootstrap runbook (one-time)

> **Status as of v1.16.0:** both Homebrew packages are now live upstream — the
> `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy`
> formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so
> `bump-homebrew.yml` finally has something to bump. **Winget is still not
> bootstrapped**: [microsoft/winget-pkgs#422752](https://github.com/microsoft/winget-pkgs/pull/422752)
> is open pending CLA + review, and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` still 404s. Neither
> is the `geode-relay` formula, which has never been submitted. Those two bump
> workflows detect the absence and skip with a `::warning::` instead of failing,
> so a green release run does *not* mean they shipped; treat the desktop app as
> GitHub-Releases-only on **Windows**.

### 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.** Both the Homebrew
cask and the Winget manifest bumps run on a maintainer's machine. The reasoning
is worth keeping, because it is the reason 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`.
- **A maintainer** merges that PR and runs `scripts/bump-homebrew-cask.sh`,
  which re-verifies the sha256 and the notarization ticket against the live
  asset before calling `brew bump-cask-pr`.

The token then lives only in that maintainer's shell:

```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_...   # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.16.0
```

Create one at
<https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump>.
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.16.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/<version>/`, 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 <dmg>` 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-<version>-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
```

---

## 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` | `~/.amethyst` (accounts + keys)<br>`~/Library/Application Support/Amethyst` (Tor)<br>`~/Library/Caches/AmethystDesktop` (image cache)<br>`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) |
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.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/` |

**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.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 <run-id> --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
<https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners>
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)
