Files
amethyst/BUILDING.md
T
Vitor PamplonaandClaude Opus 5 dc866d3568 chore(release): bump to 1.16.0
app 1.15.2 -> 1.16.0, appCode 460 -> 461. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven version
and geode's RelayInfo.VERSION; verified by reading the version back out of
:amethyst, :quartz and :geode and from a built APK (versionCode 461,
versionName 1.16.0-DEBUG).

Roughly twenty substantive PRs since v1.15.2, written up in
docs/changelog/v1.16.00.md. The ones a user feels:

- My Fitness: a training dashboard built from both sources Amethyst has --
  Health Connect and the user's own kind 1301 events -- so it works with no
  health permission at all. Workouts gains a "Mine" filter in the top nav, and
  a workout that is already published no longer offers to post itself twice.
- BOLT12 offers move into the profile payment rail, where every other payment
  route already lived, and the zap picker finally shows a bolt for a recipient
  who publishes an offer and no lightning address. A refused offer falls back
  to BOLT11, retried only on codes that mean nothing was attempted.
- Blossom uploads work again. 1.15.0's unpadded base64url token is rejected by
  deployed servers, so about two uploads in three had been failing since then.
- A relay that sends a CLOSE frame is no longer counted as connected for up to
  four minutes with its REQs live, in both the shared and the Android socket.
- Arti 2.3.0 -> 2.6.0 for TROVE-2026-24 and TROVE-2026-27.
- amy ships ~40 MB lighter after the commonsUI split.

Changelog claims were checked against the final state of each series, not the
first commit: the BOLT12 retry set is the narrowed allowlist from the audit
follow-up (a PAYMENT_FAILED can still settle, so retrying it could pay twice),
the relay count comes from the connected flow rather than the removed members
snapshot, and the OkHttp dispatcher resize is absent because it was reverted.

NEEDS A MAINTAINER BEFORE THE TAG: RELEASE_NOTES_ID still points at the v1.15.0
note. RELEASE_OPS repoints it on every x.y.0, which this is, and it has to be
published from Amethyst's own account -- so it is not something this commit
could do.

Also left alone: docs/changelog/translators.json, whose sinceLastTag is seeded
with vitorpamplona credited for all 41 languages and ~50 users with no language
at all, so scripts/translators.sh output is unusable this cycle. The changelog
credits the three translators with real attribution. mstrofnone (the desktop
keychain fixes) has no npub in mappings and is credited by handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:33:54 -04:00

40 KiB
Raw Blame History

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. This file stays fork-generic.


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)

Install Linux RPM tooling:

# Debian/Ubuntu
sudo apt-get install -y rpm fakeroot

# Fedora
sudo dnf install -y rpm-build

Install appimagetool locally (CI fetches its own — SHA-verified):

# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
sudo apt-get install -y desktop-file-utils

# 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

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 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 — run ./tools/material-symbols-subset/subset.sh
Arti (Tor) native libs amethyst/src/main/jniLibs/*.so You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries tools/arti-build/README.md

Material Symbols is mandatory after icon changes. The bundled font is a ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at runtime. Regenerate and commit the .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 + the exact Android NDK revision pinned in tools/arti-build/ANDROID_NDK_VERSION 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-*-<arch>.AppImage (x86_64 or aarch64, from host)
Linux Flatpak flatpak-builder over createReleaseDistributable output — see 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):

./gradlew :desktopApp:createReleaseDistributable

# Linux tar.gz
VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
( cd desktopApp/build/compose/binaries/main-release/app \
  && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )

# Windows .zip (PowerShell)
Compress-Archive -Path desktopApp\build\compose\binaries\main-release\app\Amethyst `
  -DestinationPath "desktopApp\build\portable\amethyst-desktop-$env:VER-windows-x64.zip"

Cross-platform architecture note: jpackage cannot cross-compile. An Intel DMG must be built on macos-13 (x64); an ARM DMG must be built on macos-14 or later. CI runs both.


Asset naming contract

All GH Release assets follow:

amethyst-desktop-<version>-<family>-<arch>.<ext>

Where:

Field Values
<version> Tag stripped of leading vX.YY.ZZ
<family> macos, windows, linux
<arch> x64, arm64
<ext> dmg, msi, zip, deb, rpm, AppImage, flatpak, tar.gz

Single source of truth: scripts/asset-name.sh. Package manager manifests (Homebrew cask, Winget) depend on this exact scheme — any change is a breaking contract.

Examples:

  • amethyst-desktop-1.12.1-macos-x64.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 → "Reproducible builds". All other native libs (secp256k1, webrtc) are version-pinned Maven prebuilts and so are byte-identical by download.

Verify a release APK reproduces

# 1. Check out the exact released tag and build the same variant unsigned.
git checkout v1.12.1
./gradlew clean :amethyst:assembleFdroidRelease

# 2. Diff your unsigned build against the published APK, ignoring only the
#    signature (META-INF/*). apksigner + a zip-aware diff is the simplest check;
#    diffoscope gives a human-readable breakdown of any remaining delta.
diffoscope \
  amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned.apk \
  amethyst-fdroid-arm64-v8a-1.12.1.apk

A clean run shows differences confined to META-INF/ (the signing files). Any diff in classes*.dex, resources.arsc, or native libs means something in the toolchain drifted — file it before publishing.


Local SonarQube analysis (opt-in)

The build supports running a SonarQube analysis against a locally hosted server. It is off by default: unless you opt in, the scanner plugin is neither downloaded nor applied and the build is unaffected.

1. Install and start a local SonarQube server

Either run the official Docker image:

docker run -d --name sonarqube -p 9000:9000 sonarqube:community

or download the Community Build zip, unzip it, and start it (requires a JDK 17+ on PATH):

cd sonarqube-<version>
bin/macosx-universal-64/sonar.sh console   # pick the folder matching your OS

Once it reports up, open http://localhost:9000 (first login admin/admin, you'll be asked to change it), create a local project named Amethyst with project key Amethyst, and generate a project analysis token for it (Project Settings → Analysis Method → With Gradle, or My Account → Security → Generate token). The token looks like sqp_….

2. Point the build at your server

Add the server and token to local.properties (gitignored — the token never lands in the repo):

sonar.host.url=http://localhost:9000
sonar.token=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

3. Run the analysis

./gradlew sonar

When it finishes, browse the results at http://localhost:9000/dashboard?id=Amethyst.

4. Optional: include Android Lint results

The scanner auto-imports each Android module's lint report and shows the findings as external issues alongside Sonar's own. It only imports — it never runs lint itself — so without the reports on disk the analysis warns Unable to import Android Lint report file(s). Generate them first, then run the scan as a separate invocation (chaining lint and sonar in one Gradle call does not guarantee lint finishes first):

./gradlew :amethyst:lintPlayDebug :benchmark:lintBenchmark :nappletHost:lintDebug
./gradlew sonar

The reports persist under each module's build/reports/, so re-run lint only when you want fresh lint data in the next scan.

Every sonar.* entry in local.properties is forwarded to the scanner, so any analysis parameter can be set there. sonar.projectKey / sonar.projectName default to the root project name (Amethyst).

Even when opted in, the scanner plugin only loads on invocations that actually request the sonar task — ordinary builds and IDE syncs are unaffected (which is also why ./gradlew tasks doesn't list it).

Note: the SonarQube Gradle scanner plugin is LGPL-3.0. It is a build-time-only tool fetched after explicit opt-in; it is never linked into shipped artifacts.


Release runbook

The release flow is driven by a tag push. Every cut ships Android + Desktop + Quartz library in one pipeline.

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

    [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:

    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 47 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.

    • 13 Android — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid .apks set built for Accrescent.

    • 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:

    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:

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:

# Android keystore → base64 for SIGNING_KEY (one line, no wrapping)
keytool -genkey -v -keystore upload.jks -keyalg RSA -keysize 2048 \
  -validity 10000 -alias upload          # creates the keystore (once)
base64 -i upload.jks | tr -d '\n'        # paste output into SIGNING_KEY

# GPG key → armored private key for SIGNING_PRIVATE_KEY
gpg --full-generate-key                  # create the key (once)
gpg --armor --export-secret-keys <KEY_ID>   # paste output into SIGNING_PRIVATE_KEY

# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12.
# In Keychain Access, export the "Developer ID Application: ..." cert (with its
# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD).
base64 -i developer_id.p12 | tr -d '\n'  # paste output into MAC_CERTIFICATE_P12
security find-identity -v -p codesigning  # shows the exact MAC_SIGN_IDENTITY string
# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com
# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login.

SONATYPE_USERNAME/SONATYPE_PASSWORD are a user token from https://central.sonatype.com (Account → Generate User Token), not your login. A fork that doesn't publish a library can drop the Publish Quartz Lib step and the four Sonatype/GPG secrets.


Distribution channels

One v* tag fans out to several channels. Which apply depends on where a fork distributes; the official Amethyst rollout for each is in RELEASE_OPS.md.

Channel How it ships Push or pull
GitHub Releases The release workflow builds + signs all assets and attaches them to the tag's Release Automatic (CI)
Maven Central Same workflow runs publishAllPublicationsToMavenCentral for quartz — 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 repo, then signs + publishes itself Pull (build-from-source)
Zapstore The zsp CLI reads zapstore.yaml and publishes a Nostr software-release event signed with the app's nsec Manual push (Nostr)
Homebrew + Winget bump-homebrew.yml / bump-winget.yml open version-bump PRs on stable tags — 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 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. 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:

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:

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:

# 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.

To submit:

# 1. Grab the published asset's sha256
curl -fsSL -o amy-jvm.tar.gz \
  https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
shasum -a 256 amy-jvm.tar.gz

# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR
brew create --set-name amy --tap homebrew/core \
  https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
#    (paste the reference formula body, run `brew audit --new amy`,
#     `brew install --build-from-source amy`, `brew test amy`, then PR it.)

Caveats that the maintainer must weigh before submitting:

  • Name collision. 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)

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

chmod +x Amethyst-*.AppImage
./Amethyst-*.AppImage

On Fedora Silverblue / very minimal distros, FUSE might be missing. Use --appimage-extract-and-run:

./Amethyst-*.AppImage --appimage-extract-and-run

Uninstall + state paths

State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb, .rpm, AppImage, tar.gz). Switching channels does not duplicate data but may expose downgrade migration risks — prefer a single install channel per machine.

Exception: Flatpak. The sandbox redirects XDG dirs into ~/.var/app/com.vitorpamplona.amethyst.Desktop/, so a Flatpak install keeps its own separate state and does not see (or risk downgrading) state written by any other channel.

OS App location State directories
macOS /Applications/Amethyst.app ~/.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.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):
    gh release edit v1.08.1 --prerelease
    
  2. Delete the bad asset:
    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:
    gh run rerun <run-id> --failed
    
  4. Flip back to stable once verified (re-fires bump workflows — confirm fix first):
    gh release edit v1.08.1 --prerelease=false
    

Bad build reached Homebrew

Preferred: ship a point release (e.g. v1.08.2) — users on v1.08.1 get the fix via brew upgrade.

Alternative: close the open PR in Homebrew/homebrew-cask before merge, or file a revert PR if already merged. Typical Homebrew turn-around: 1–2 days.

Bad build reached Winget

Winget manifests are append-only — no hard unpublish. Options:

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

# 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)