Quartz moves to its own repo, the way the crypto primitives moved to crypto-kmp. Every module depends on com.vitorpamplona.quartz:quartz 2.0.0 (libs.quartz) instead of :quartz, and -PquartzDir=../quartz builds a local checkout instead of the artifact, for every module and for quic's own quartz dependency (which replaces the root build's substitution of the published quartz with :quartz). What moved with quartz: its CI jobs (lint tasks, test-quartz-linux-native, test-quartz-ios), the Maven Central publish step of the release workflow, jitpack.yml, tools/unicode-nfkc, the nostr-expert and event-store-semantics skills, the agent-skill check, and the README's library usage docs. What stays here: - The four quartz client tests that need a real Ktor socket relay (TrustNetworkSync*, GiantReqStreamTest, BulkDownloadBenchmark) move to geode's tests; geode forwards -PprodRelayBench for the latter. quartz's other relay-client tests now use a test relay of its own, which ends quartz's test dependency on geode. - The benchmark module's resources (previously read from quartz's androidDeviceTest resources) move into benchmark/; relayBench --real reads the corpus from there. - NoteComposeKindCoverageTest reads EventFactory.kt from quartz's JVM sources jar, or from the -PquartzDir checkout, passed by commonsUI's build. - commons and commonsUI declare crypto-kmp (and commons mls-kmp), which they import directly. Through quartz's api alone, a -PcryptoKmpDir checkout never reached the common metadata compile. - The catalog drops what only quartz used (kchesslib, negentropy, jackson-databind, sqlite-bundled, secp256k1-kmp common, compose runtime-annotation, the maven-publish plugin). agent_config_check checks a skill's quartz/... paths against a ../quartz checkout when there is one and skips them otherwise. AGENTS.md, the skills, CONTRIBUTING, BUILDING, RELEASE_OPS, PLANS and the READMEs point at the new repo. Needs quartz 2.0.0 (and crypto-kmp 1.0.0) on Maven Central before CI can resolve it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEkJaDWYrcTbGvT8JLQCrG
31 KiB
Release Ops (Amethyst maintainers)
This is the operational checklist the Amethyst team follows to ship a
release — the account-specific, push-the-buttons side of cutting a version.
The generic build/release mechanics (how the CI pipeline works, the asset
naming contract, the secret names a fork must set, desktop packaging) live in
BUILDING.md. Read that first; this doc only covers what is
specific to shipping the official Amethyst artifacts.
Forks: you do not need this file.
BUILDING.mdhas everything you need to build and release your own fork. This describes our accounts and channels.
At a glance
A release is one tag push that fans out to these distribution channels:
| Channel | Mechanism | Who pushes |
|---|---|---|
| GitHub Releases | Automatic — the Create Release Assets workflow builds + signs everything on the v* tag |
CI |
| Google Play | Manual — download the signed AAB from the GH Release, upload in Play Console | Maintainer |
| F-Droid | Pull — F-Droid's build server builds the fdroid flavor from source when it sees the new tag |
F-Droid (we just maintain the recipe + metadata) |
| Zapstore | amy releases publish signs the APK's NIP-82 events (as zsp did), reading zapstore.yaml, with Amethyst's nsec |
Maintainer |
| Orchard | The same amy releases publish run signs NIP-82 events naming the GitHub Release's .flatpak bundles |
Maintainer |
| Desktop self-update | The same amy releases publish run signs the NIP-82 events the desktop app updates from |
Maintainer |
| Flathub | Not submitted yet. Once accepted: a PR to the Flathub app repo with the regenerated source manifest | Maintainer, by hand (Flathub's AI policy) |
| Homebrew | Autobump — BrewTestBot opens the amethyst-nostr cask and amy formula PRs itself; scripts/bump-homebrew-cask.sh only verifies |
Homebrew (we verify) |
| Winget | scripts/bump-winget.sh opens the winget-pkgs PR with your gh auth |
Maintainer |
Maven Central (the quartz library) also publishes automatically from the same
workflow — as a step at the end of the deploy-android job, not a job of its
own, so don't expect to find it in the run's job list.
1. Pre-tag checklist
-
Bump the version in
gradle/libs.versions.toml— both keys:app = "1.12.1" # semver, drives every module + the tag appCode = "449" # Android versionCode, monotonic — must incrementThat single edit propagates to Android (
versionName/versionCode), Desktop & CLI (packageVersion),quartz(Maven version) andgeode(RelayInfo.VERSION). Nothing else hardcodes the version. -
Write the changelog as
docs/changelog/vMAJOR.MINOR.PP.md(zero-padded, e.g.v1.12.01.md) and add it todocs/changelog/README.md. Follow the house style: plain text, short verb-first sentences.For the
## Contributorssection, generate the credits from git instead of writing them by hand:scripts/contributors.shIt lists the commit authors and
Co-authored-bytrailers since the last release tag, resolved to npubs throughdocs/changelog/github.json(mappingsby GitHub username,aliasesfor other names and emails a person commits under,ignorefor AI co-authors and bots). Anyone it can't resolve is listed underUNMAPPEDwith a commit count — credit them by hand, then add their npub or an alias so future releases pick them up. Pass--from/--toto rebuild an older release's list.For the
## Translationssection, generate the credits instead of writing them by hand — no token needed:scripts/translators.shThis runs offline. It reads
docs/changelog/translators.json(kept next to the changelogs), which CI keeps fresh: the Crowdin sync workflow'sseed-translatorsjob records everyone who has translated since the lastv*tag, with their languages, in the file'ssinceLastTaglist, and accumulates their npubs in the forever-growingmappingsregistry. The script resolves that list to npubs and prints the## Translationsblock, one line per translator with all their languages. Usernames underignore(the account that uploads machine translations) are left out, so credit translations that arrived through git rather than Crowdin by hand. Contributors with no npub yet are listed underUNMAPPED— credit them by hand, then add their npub undermappingsso future releases pick them up automatically. (To re-query Crowdin live as a sanity check, runscripts/translators.sh --seedwithCROWDIN_PROJECT_ID/CROWDIN_PERSONAL_TOKENset, which refreshes the file.) -
Publish the release-notes note on Nostr — minor releases only. In practice this id has only ever been bumped on
x.y.0(1.11.0, 1.12.0, 1.13.0); patch releases leave it pointing at their minor's note. Publish with Amethyst's account and paste the event id intoamethyst/build.gradle.kts:buildConfigField("String", "RELEASE_NOTES_ID", "\"<new-event-id-hex>\"")This id is what the in-app drawer's "Release Notes" link and the donation card open (
DrawerContent.kt,ShowDonationCard.kt). It must point at the note for this version, so publish the note before tagging and commit the new id together with the version bump. -
Add the release to the Linux metainfo: a new first entry in
<releases>ofdesktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Amethyst.metainfo.xml(<release version="1.12.1" date="YYYY-MM-DD">with adetailsURL to the GitHub release, as the existing entries do). If you forget, release CI prints a warning and injects a bare entry, but only into the bundle it builds: AppStream readers like Flathub take the history from the source tree. -
Sanity-build locally (optional but cheap):
./gradlew assembleReleaseand a desktoppackageDistributionForCurrentOS, or run the workflow's dry-run (see BUILDING.md § Dry-run).
2. Cut the release
Commit, tag, push — see BUILDING.md § Release runbook
for the exact commands. The tag must equal app from the catalog (the workflow
asserts this and fails fast otherwise). A clean vMAJOR.MINOR.PATCH tag is
classified stable and runs the Homebrew/Winget bump workflows; anything with
a -rc/-beta/-alpha/-dev suffix is a prerelease and skips them.
Heads-up on the git push: this repo has git-credential-manager configured as
a credential helper, and it blocks on an interactive prompt (a plain
GIT_TERMINAL_PROMPT=0 does not stop it — the push just hangs). If that
happens, push using gh's helper for the one command:
git -c credential.helper= -c credential.helper='!gh auth git-credential' push upstream main
When the Create Release Assets workflow finishes (~25–30 min) the GH Release
holds 47 assets, per the asset-name contract:
- Android (13): 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid
.apksset for Accrescent (amethyst-googleplay-*-v…apk/.aab,amethyst-fdroid-*-v…apk/.aab/.apks) - Desktop (14): macOS DMG (arm64 only — there is no Intel DMG), Windows MSI (x64 only — no arm64 MSI) + portable zip (x64, arm64), and Linux DEB/RPM/AppImage/flatpak/tar.gz in both x64 and arm64. BUILDING.md § Release runbook has the per-leg breakdown and why the two gaps exist.
- CLI (10): the
amyartifacts — the no-JREjvm.tar.gz, macOS arm64, Windows x64 + arm64, and Linux DEB/RPM/tar.gz in both x64 and arm64 - Relay (10): the
geodeartifacts, same matrix asamy. The geode Docker image is not a release asset — it goes to the registry, so don't count it here.
3. Per-channel shipping
GitHub Releases — automatic
Nothing to do beyond pushing the tag. Verify the asset count (BUILDING.md
§ Verify). macOS is arm64-only — there is no Intel DMG, so a single
amethyst-desktop-<version>-macos-arm64.dmg is the expected, correct result.
Two of those assets are the R8 mapping files
(amethyst-{googleplay,fdroid}-mapping-<version>.txt.gz). Do not prune them
from old releases — they are the only way to read a crash report from a build
that old (§ 7).
Google Play — manual upload
- Download
amethyst-googleplay-<version>.aabfrom the GH Release. - Play Console → app
com.vitorpamplona.amethyst→ Production (or the staged-rollout track we're using) → create release → upload the AAB. - The release notes field can reuse the
docs/changelogtext. - Roll out.
F-Droid — pull / build-from-source
F-Droid does not accept an upload from us. Its build server polls the repo,
and when it sees the new v* tag it builds the fdroid product flavor from
source (reproducibly) per the recipe in the separate
fdroiddata repo
(metadata/com.vitorpamplona.amethyst.yml), then signs and publishes to the
F-Droid repo on its own cadence.
What we own to keep that working:
- The
fdroidflavor (amethyst/src/fdroid/…) must stay free of proprietary deps — it swaps Firebase/Google services for UnifiedPush and no-op/open implementations (ML Kit, writing assistant, push). Google-only libraries live behind theplayflavor. - The fastlane metadata under
fastlane/metadata/android/(descriptions, images). F-Droid reads per-version changelogs fromfastlane/metadata/android/en-US/changelogs/<versionCode>.txtif present — add one (e.g.449.txt) when we want a changelog shown on F-Droid; otherwise none is displayed. - The
AutoUpdateMode/UpdateCheckModein the fdroiddata recipe tracks tags, so a correctvX.Y.Ztag + bumpedversionCodeis usually all F-Droid needs.
After a release, just confirm F-Droid picked up the new version (it can lag a few days): https://f-droid.org/packages/com.vitorpamplona.amethyst/.
Nostr releases — amy releases publish with Amethyst's nsec
One command announces the release on Nostr (NIP-82) for three readers, each under its own app id:
- Zapstore (zapstore.dev) installs and updates the APK from events
under
com.vitorpamplona.amethyst.amywrites them tag for tag aszspdid, reading the samezapstore.yaml, so installs Zapstore made keep updating. - Orchard (github.com/Letdown2491/orchard), a Flatpak
store over Nostr, installs the GitHub Release's
.flatpakbundles from events undercom.vitorpamplona.amethyst.Amethyst(§ Orchard). - The desktop app updates itself from events under
com.vitorpamplona.amethyst.desktop. Until they are out, a desktop user only hears about the new version from GitHub, with a link to the download page; the app never installs a file a signed release does not name (desktopApp/plans/2026-10-15-desktop-self-update-study.md).
The three share the key and the event kinds, so they must never share an app id: one publish would replace another's release.
Each release, after the GH Release assets exist, download them into one folder and run from
the repo root (for zapstore.yaml), logged in to amy with Amethyst's nsec:
gh release download vX.Y.Z --pattern 'amethyst-desktop-*' --pattern 'amethyst-googleplay-arm64-v8a-*.apk' --dir /tmp/release-vX.Y.Z
amy releases publish /tmp/release-vX.Y.Z --version X.Y.Z --notes-file notes.md --blossom https://cdn.zapstore.dev --dry-run # check
amy releases publish /tmp/release-vX.Y.Z --version X.Y.Z --notes-file notes.md --blossom https://cdn.zapstore.dev
For each app with files in the folder, it signs one kind 3063 asset per file, then the kind 30063
release that names them, and publishes to that app's relays. --listing also (re)publishes the kind 32267 listing: do it when
zapstore.yaml (Android), FlatpakRelease (Flatpak) or DesktopReleaseEvents (desktop)
changes, and the first time for the desktop.
- Android: by default the googleplay arm64-v8a APK, the one
zsppicked and the only platform Zapstore installs for (--android-flavor,--android-abischoose others). Each APK is checked with apksig (signature, package, version) and tagged withversion_code, the SDK range, its ABIs,apk_certificate_hash, thevariantand thesupported_nipsfromzapstore.yaml.--blossom https://cdn.zapstore.devmirrors the APK there as a second URL, and for--listinguploads the icon and screenshots, aszspdid. Relays:relay.zapstore.dev(the one the Zapstore app reads),nos.lol,nostr.momandvitor.nostr1.com. - Flatpak: both
amethyst-desktop-X.Y.Z-linux-*.flatpakbundles, each checked to be this app for its arch by the ref in its header, with its GitHub URL, SHA-256, size, arch andapplication/vnd.flatpak, in one release. Relays:FlatpakRelease.RELAYS(relay.orchard.rocksis the one Orchard reads). - Desktop: every
amethyst-desktop-X.Y.Z-*file the app installs (DMG, MSI, both zips, both AppImages, both tarballs, the .debs and .rpms; not the Flatpak bundles or.zsyncfiles), with its GitHub URL, SHA-256, size, platform and MIME type. Relays:DesktopRelease.RELAYS.
It refuses any key but Amethyst's. A prerelease version (anything but X.Y.Z, as the release
workflow classifies tags) goes out on the beta channel, which none of the readers installs from.
--only android, --only flatpak or --only desktop publishes one app.
zsp publish still works for the APK if amy is unavailable: it reads the same zapstore.yaml,
with the relays in the RELAY_URLS env var.
Orchard
Orchard (0.2.0 and later) downloads the bundle from the asset's URL, checks it against the signed
SHA-256 and installs it with flatpak install --bundle; an update installs the new bundle over the
old one. There is nothing to build or host beyond the GitHub Release. Orchard 0.1.x only installed
OCI images, which orchard-publish pushed to ghcr.io/vitorpamplona/amethyst-flatpak for 1.19.0;
the 1.19.0 events still point there, so leave that package until a later release is out, then it
can be deleted.
The bundle is built on the Flatpak branch master; the 1.19.0 image was stable. Someone who
installed 1.19.0 from Orchard gets the bundle as a second branch beside the image's on the next
update; flatpak uninstall com.vitorpamplona.amethyst.Amethyst//stable removes the old one.
One-time setup. orchard.txt at the repo root names Amethyst's npub. Orchard reads it from the
default branch to show the repository link as verified. Update it if the signing key ever changes.
Flathub — not submitted yet
Amethyst is not on Flathub. The source-build manifest that a submission would
use lives in
desktopApp/packaging/flatpak/flathub/,
and its README section lists
what is still missing (AI disclosure, domain verification, a tag whose metainfo
has the screenshot). Until then there is nothing to ship here; the steps below
are for once Flathub has created flathub/com.vitorpamplona.amethyst.Amethyst.
Flathub's AI policy. These files were written with Claude Code. Flathub forbids AI tools from opening or automating its PRs and from writing their descriptions or review replies, so a person opens each update PR there and answers the review. An agent may prepare the files in this repo.
Each release, after the tag is pushed:
- Regenerate the Maven list from a real online build of the tag, both arches
in Docker (about 35 minutes; work files go to
build/flathub-regen/):It rewritesdesktopApp/packaging/flatpak/flathub/tools/regenerate-gradle-sources.sh vX.Y.Zgradle-sources.jsonand prints the tag tarball's sha256. - In
flathub/com.vitorpamplona.amethyst.Amethyst.yml, point thearchivesource at the tag's URL and that sha256. When the JetBrains Runtime moves (JBR_BUILDin the regenerate script and the twojbrsdksources), bump both and their sha256s together. - Build and lint it locally (below), commit the three files here, then copy
the manifest,
flathub-offline.init.gradleandgradle-sources.jsoninto a PR to the Flathub app repo. Flathub's update bot can bump a URL, but not regenerate the Maven list, so every release is this manual PR.
Testing locally. flatpak-builder needs Linux; on macOS run it in a
privileged Debian container (flatpak flatpak-builder elfutils installed, the
freedesktop 26.08 Platform and Sdk from Flathub). From the flathub/ directory:
flatpak-builder --force-clean --repo=repo --mirror-screenshots-url=https://dl.flathub.org/media/ \
builddir com.vitorpamplona.amethyst.Amethyst.yml # add --disable-rofiles-fuse in Docker
ostree commit --repo=repo --canonical-permissions \
--branch=screenshots/$(flatpak --default-arch) builddir/files/share/app-info/media
flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest com.vitorpamplona.amethyst.Amethyst.yml
flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
The build step has no network, so a Maven file missing from
gradle-sources.json fails Gradle's resolution there. Expected noise: built
with Debian's flatpak-builder/AppStream, the repo lint reports
appstream-external-screenshot-url and appstream-remote-icon-not-mirrored,
because that AppStream writes media paths relative to media_baseurl, while
the linter wants the full dl.flathub.org/media URLs Flathub's own builder
writes. Anything else it reports is real. Then install it and run it:
flatpak remote-add --user --no-gpg-verify amethyst-local repo
flatpak install --user amethyst-local com.vitorpamplona.amethyst.Amethyst
flatpak run com.vitorpamplona.amethyst.Amethyst
The app should open, and lib/Amethyst/lib/runtime/lib/libawt_wlawt.so should
be in the installed tree (JBR's Wayland toolkit). builddir/, repo/ and
.flatpak-builder/ are gitignored.
Homebrew + Winget
The in-repo sync workflows (bump-homebrew.yml, bump-homebrew-formula.yml,
bump-homebrew-geode-formula.yml, bump-winget.yml) keep the reference
packaging files current. Upstream:
| Channel | Upstream package | State |
|---|---|---|
| Homebrew cask | Homebrew/homebrew-cask → amethyst-nostr |
Live, autobumped — BrewTestBot opens the version PR (v1.17.0: #292108) |
| Homebrew formula | Homebrew/homebrew-core → amy |
Live, autobumped — BrewTestBot opens and merges the version PR (v1.17.0: #315679) |
| Homebrew formula | Homebrew/homebrew-core → geode-relay |
Not submitted — renamed from geode, which is permanently reserved for Apache Geode |
| Winget | microsoft/winget-pkgs → VitorPamplona.Amethyst |
Live — initial PR #422752 merged 2026-09-23; bumped per release by scripts/bump-winget.sh |
Homebrew is autobumped. Both packages are on Homebrew's autobump list
(autobump: true in https://formulae.brew.sh/api/cask/amethyst-nostr.json and
…/api/formula/amy.json), so BrewTestBot opens each version-bump PR itself,
roughly every 3 hours after the release, and brew bump-cask-pr refuses to
open a competing one. There is nothing to push. What BrewTestBot does not do is
check our DMG: it computes its own sha256 and never looks at notarization. So
scripts/bump-homebrew-cask.sh now verifies instead of submitting — it
re-checks the live DMG's sha256 and stapled notarization ticket, finds
BrewTestBot's PR, and fails if the sha256 in that PR differs from ours. It
needs no token. Only if Homebrew ever drops the cask from autobump does it fall
back to brew bump-cask-pr, which needs HOMEBREW_GITHUB_API_TOKEN (classic
PAT, repo scope) in your shell.
The state above is a snapshot; re-check before assuming. Two calls answer it:
gh api repos/microsoft/winget-pkgs/contents/manifests/v/VitorPamplona and
curl -s -o /dev/null -w '%{http_code}' https://formulae.brew.sh/api/cask/amethyst-nostr.json
(404 = still absent).
Two separate faults kept this invisible until v1.13.1, both now fixed:
- The workflows never ran at all — for any release. They triggered on
release: types: [released], and GitHub does not raise workflow-triggering events for a release created byGITHUB_TOKEN, which is exactly howcreate-release.ymlcreates it. They now trigger onworkflow_runafterCreate Release Assetssucceeds, which also fixes a latent race — the old event fired while assets were still uploading. - Nothing exists upstream to bump.
brew bump-cask-prandwinget-releasercan only update an existing package. The first submission is a manual, human-reviewed PR: BUILDING.md § Homebrew cask (one-time initial PR) and § Winget (one-time initial submission).
CI does the bookkeeping with GITHUB_TOKEN only — verifying the artifact,
computing hashes, and opening an in-repo PR syncing the reference packaging
files. After merging those sync PRs, a maintainer runs:
git pull # the scripts read the merged files
scripts/bump-homebrew-cask.sh v1.19.0 # verify only; BrewTestBot opens the PR
scripts/bump-winget.sh v1.19.0 # opens the winget-pkgs PR; uses your `gh` auth
Both scripts re-verify the published artifact's sha256 first, and the Homebrew one also refuses a DMG without a stapled notarization ticket. Run the Homebrew one again later if BrewTestBot has not opened its PR yet. See BUILDING.md § Package-manager credentials.
All four in-repo sync workflows (amy formula, geode formula,
amethyst-nostr cask, winget manifests) open PRs against this repo on every
release. Merge them to keep the reference packaging files current.
4. Operated infrastructure
Push notification server
The Google Play (FCM) flavor delivers push through a server we operate at
push.amethyst.social, built from
vitorpamplona/amethyst-push-notif-server.
It registers devices, watches their NIP-65 inbox / NIP-17 DM relays, and sends
wake-up pushes.
playflavor → push via this server (Firebase/FCM).fdroidflavor → UnifiedPush through a distributor app the user installs (e.g. ntfy); it does not use our server.- Both are complemented by the on-device always-on
NotificationRelayService(seePULL_NOTIFICATION.md), which keeps the user's relay connections alive without any push server at all.
The push server has its own repo, deploy, and release cadence — a normal app release does not redeploy it. Coordinate a server deploy only when the app changes the registration/push contract (token format, payload, or endpoint), so the running server stays compatible with the shipped app.
5. Secrets ownership & rotation
The workflow's required secrets and what they sign are inventoried generically
in BUILDING.md § Secrets. Amethyst-specific
ownership:
| Secret(s) | Protects | Rotation |
|---|---|---|
SIGNING_KEY, KEY_ALIAS, KEY_STORE_PASSWORD, KEY_PASSWORD |
The Android upload keystore — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) |
SONATYPE_USERNAME, SONATYPE_PASSWORD |
Maven Central namespace com.vitorpamplona |
On compromise |
SIGNING_PRIVATE_KEY, SIGNING_PASSWORD |
The GPG key signing Maven artifacts | Per GPG key expiry |
| (none for Homebrew/Winget) | — | Homebrew is autobumped by BrewTestBot, and scripts/bump-winget.sh runs on a maintainer's machine with gh auth, so neither channel needs a CI secret. See BUILDING.md § Package-manager credentials |
CROWDIN_PERSONAL_TOKEN, CROWDIN_PROJECT_ID |
Translation sync | On compromise |
Owner assignments and rotation reminders live with the team (issue tracker).
6. Post-release verification
- GH Release: 49 assets, sizes sane, and the asset-name set matches the
previous release (see the
diffone-liner in BUILDING.md § Release runbook). macOS is arm64-only — do not look for an Intel DMG. - Maven Central:
quartz:<version>resolves (allow tens of minutes of propagation; the publish step's log is the authoritative signal). - Play Console: rollout started, no policy rejection.
- Zapstore: release event visible.
- Orchard: the Amethyst row shows the new version, and its repository link
wears the check (the
orchard.txtproof). - F-Droid: new version detected (may lag days).
- Flathub (only once Amethyst is on Flathub): the app-repo PR with the
regenerated
gradle-sources.jsonis merged and Flathub's build is green. - Four sync PRs opened against this repo (
amyformula,geodeformula,amethyst-nostrcask, winget manifests) — merge them. - Homebrew:
scripts/bump-homebrew-cask.sh vX.Y.Zpasses — DMG verified and BrewTestBot's cask PR carries the same sha256 (no token; re-run later if the bot has not opened it yet).amyneeds nothing: BrewTestBot bumps it. - Winget pushed upstream:
scripts/bump-winget.sh vX.Y.Z(manual, no token — uses yourghauth). - In-app "Release Notes" link opens the note matching
RELEASE_NOTES_ID(only bumped on minor releases — patches keep pointing at the x.y.0 note). - Push still works on a
playbuild (only if the push contract changed — see § 4); UnifiedPush still works on anfdroidbuild.
If anything ships broken, see BUILDING.md § Incident response.
7. Crash reports & retrace
Release builds are minified and obfuscated (they have to be: Play Console
drops apps whose DEX is under 25% optimized or obfuscated out of store surfaces
— see amethyst/proguard-rules.pro for the whole story). So a raw stack trace
from a release build looks like this:
java.lang.IllegalStateException: something blew up
at onh.B(r8-map-id-12c710927a584543dbe1e2e867db95460bc44482efe53283f86798648c1cfc00:7)
That is not lost information, it is encoded information. Paste the report in and run it:
scripts/retrace.sh crash-report.txt
pbpaste | scripts/retrace.sh # or straight off the clipboard
Nothing else to supply. A report's first line names its own build —
java.lang.IllegalStateException: 1.16.0-PLAY — so the script resolves the tag
(v1.16.0) and the flavor (PLAY → googleplay), downloads that release's
mapping asset and caches it. For a bare stack trace with no such header, name
the build yourself with --release v1.16.0 --flavor play; for a build you made
locally, pass its mapping.txt directly.
java.lang.IllegalStateException: something blew up
at androidx.compose.foundation.text.input.TextFieldCharSequence.getText(TextFieldCharSequence.kt:58)
at androidx.compose.foundation.text.input.TextFieldState.getText(TextFieldState.kt:146)
at com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel.onMessageChanged(ShortNotePostViewModel.kt:1727)
Note that retrace gave back three frames where the crash reported one: R8 had inlined two of them. That is worth internalising — it is the reason a raw trace's line number cannot be trusted even in the pre-obfuscation builds, where optimization was already inlining. Retracing is not a tax obfuscation imposed; it is how you read an optimized build at all.
The wrong mapping is worse than none — it produces confident, wrong names.
So the script refuses to guess: the r8-map-id-<hash> in the trace is the
pg_map_id header of the mapping that built it, and the two are compared before
anything is printed:
error: this mapping did not build this report.
report: deadbeef...
mapping: 12c71092... (amethyst-googleplay-mapping-v1.16.0.txt.gz)
--force overrides if you really mean it. (googleplay vs fdroid matters —
the two flavors are separate R8 runs with different mappings.)
Per channel:
| Where the report came from | What to do |
|---|---|
| Play Console / Android vitals | Nothing. AGP embeds the mapping in the .aab (BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map), so Play deobfuscates automatically. |
| A NIP-17 DM from the in-app crash reporter, a GitHub issue, F-Droid, Zapstore, Accrescent | scripts/retrace.sh <report> — it reads the build off line 1 and fetches the mapping. |
| A build you made locally | scripts/retrace.sh amethyst/build/outputs/mapping/<variant>/mapping.txt report.txt |
scripts/retrace.sh downloads the R8 version named in the mapping's own header
from Google's Maven and caches it, so it needs no pinned tooling and keeps
working across AGP bumps. It needs no gh auth either — release assets on a
public repo are plain HTTPS downloads.
Two details of the report format in ReportAssembler exist for this and should
not be "tidied" away: the headline carries the fully qualified exception
class (a bare simpleName obfuscates to a, which retrace cannot resolve
because it has no package), and stack frames are written as at <frame>
(retrace only rewrites frames it recognises, and it recognises them by the
leading at). The script repairs the missing at on reports from older builds,
but new reports should not need repairing.
Do not delete mapping assets from old releases. They are the only copy — CI's are gone when the job ends, and a mapping cannot be regenerated after the fact (it would need a bit-identical rebuild, and R8's renaming is not stable across runs).
Did obfuscation break anything?
R8 cannot see reflection, so nothing in the build tells you that a keep rule
stopped matching. tools/r8-verify/reflection-contract.txt lists every place
something outside the DEX resolves a name at runtime — JNI symbols, Jackson DTO
field names, enum constants persisted in DataStore, WorkManager's stored worker
class names, the Cast OptionsProvider named in a manifest <meta-data> value
— and the release workflow asserts each one against what R8 actually emitted,
for both flavors, before any asset is collected:
python3 tools/r8-verify/verify_reflection_contract.py \
amethyst/build/outputs/mapping/playRelease/
Run it on any local minified build too. It takes under a second and needs no device.
Adding reflection means adding two things: the keep rule, and a line in the contract. A rule with no contract line is unverified and will rot silently.
What this does not cover is reflection nobody wrote down. For that the honest controls are a staged Play rollout (the crash reporter retraces itself now, so a break is legible within hours) and exercising NIP-47 wallet connect, NIP-46 bunker login, Tor, scheduled posts and a settings round-trip on a minified build before shipping.