Files
amethyst/RELEASE_OPS.md
Vitor PamplonaandClaude Opus 5.5 6d09e145af docs(release): add Flathub to RELEASE_OPS.md
The source-build manifest landed in desktopApp/packaging/flatpak/flathub/
(#4474) with no entry in the release runbook, so nothing reminded a
release to keep it current. RELEASE_OPS.md now has:

- a Flathub row in "At a glance": not submitted yet;
- a Flathub section after Orchard: status and what blocks submission
  (pointing at the flatpak README), that a person opens and answers the
  Flathub PRs (its AI policy), the per-release steps (regenerate
  gradle-sources.json, bump the archive url/sha256 and JBR, copy three
  files into the app-repo PR), and the local build, screenshots-ref and
  lint commands, with the two media-URL lint errors Debian's AppStream
  produces called out as expected;
- a § 6 checklist line, for once Amethyst is on Flathub.

flathub/.gitignore covers the local builddir/, repo/ and
.flatpak-builder/ that those commands leave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:48:23 -04:00

32 KiB
Raw Permalink Blame History

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.md has 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 zsp publish reads zapstore.yaml, signs a Nostr release event with Amethyst's nsec Maintainer
Orchard orchard-publish pushes the Flatpak to ghcr and signs its listing with Amethyst's nsec 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

  1. 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 increment
    

    That single edit propagates to Android (versionName/versionCode), Desktop & CLI (packageVersion), quartz (Maven version) and geode (RelayInfo.VERSION). Nothing else hardcodes the version.

  2. Write the changelog as docs/changelog/vMAJOR.MINOR.PP.md (zero-padded, e.g. v1.12.01.md) and add it to docs/changelog/README.md. Follow the house style: plain text, short verb-first sentences.

    For the ## Contributors section, generate the credits from git instead of writing them by hand:

    scripts/contributors.sh
    

    It lists the commit authors and Co-authored-by trailers since the last release tag, resolved to npubs through docs/changelog/github.json (mappings by GitHub username, aliases for other names and emails a person commits under, ignore for AI co-authors and bots). Anyone it can't resolve is listed under UNMAPPED with a commit count — credit them by hand, then add their npub or an alias so future releases pick them up. Pass --from/--to to rebuild an older release's list.

    For the ## Translations section, generate the credits instead of writing them by hand — no token needed:

    scripts/translators.sh
    

    This runs offline. It reads docs/changelog/translators.json (kept next to the changelogs), which CI keeps fresh: the Crowdin sync workflow's seed-translators job records everyone who has translated since the last v* tag, with their languages, in the file's sinceLastTag list, and accumulates their npubs in the forever-growing mappings registry. The script resolves that list to npubs and prints the ## Translations block, one line per translator with all their languages. Usernames under ignore (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 under UNMAPPED — credit them by hand, then add their npub under mappings so future releases pick them up automatically. (To re-query Crowdin live as a sanity check, run scripts/translators.sh --seed with CROWDIN_PROJECT_ID / CROWDIN_PERSONAL_TOKEN set, which refreshes the file.)

  3. 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 into amethyst/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.

  4. Add the release to the Linux metainfo: a new first entry in <releases> of desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Amethyst.metainfo.xml (<release version="1.12.1" date="YYYY-MM-DD"> with a details URL 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.

  5. Sanity-build locally (optional but cheap): ./gradlew assembleRelease and a desktop packageDistributionForCurrentOS, 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 .apks set 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 amy artifacts — the no-JRE jvm.tar.gz, macOS arm64, Windows x64 + arm64, and Linux DEB/RPM/tar.gz in both x64 and arm64
  • Relay (10): the geode artifacts, same matrix as amy. The geode Docker image is not a release asset — it goes to the registry, so don't count it here.
  • Maven Central: com.vitorpamplona.quartz:quartz:<version> published. repo1.maven.org lags the publish by tens of minutes — a 404 right after the run is normal. Confirm the step's log says "Deployment is being published to Maven Central", and compare against the previous version's POM before concluding anything is broken.

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

  1. Download amethyst-googleplay-<version>.aab from the GH Release.
  2. Play Console → app com.vitorpamplona.amethyst → Production (or the staged-rollout track we're using) → create release → upload the AAB.
  3. The release notes field can reuse the docs/changelog text.
  4. 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 fdroid flavor (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 the play flavor.
  • The fastlane metadata under fastlane/metadata/android/ (descriptions, images). F-Droid reads per-version changelogs from fastlane/metadata/android/en-US/changelogs/<versionCode>.txt if present — add one (e.g. 449.txt) when we want a changelog shown on F-Droid; otherwise none is displayed.
  • The AutoUpdateMode/UpdateCheckMode in the fdroiddata recipe tracks tags, so a correct vX.Y.Z tag + bumped versionCode is 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/.

Zapstore — zsp publish with Amethyst's nsec

Zapstore is a Nostr-native app store. The zsp CLI reads zapstore.yaml at the repo root (name, summary, description, tags, license, icon, screenshots, supported_nips, and the variants regexes that match our *-fdroid-*.apk / *-googleplay-*.apk GH-release assets), then publishes a signed software-release event to Nostr relays.

# from the repo root, after the GH Release assets exist
zsp publish

It signs with Amethyst's nsec — provide the key the way zsp expects (SIGN_WITH env var / prompt / its own config), never commit it.

Relays. zsp does not take relays from zapstore.yaml; it reads the RELAY_URLS env var (comma-separated) and defaults to wss://relay.zapstore.dev when unset. To fan the release event out to more relays for discoverability, set RELAY_URLS for the run:

RELAY_URLS="wss://relay.zapstore.dev,wss://nos.lol,wss://nostr.mom,wss://vitor.nostr1.com" \
  SIGN_WITH=<amethyst-nsec> zsp publish

Keep wss://relay.zapstore.dev in the list — that is the relay the Zapstore app itself reads from.

Orchard — orchard-publish with Amethyst's nsec

Orchard is a Flatpak store over Nostr. Its publisher CLI, orchard-publish, turns the Flatpak's app tree into an OCI image, pushes it to our ghcr registry, then signs three events with Amethyst's key: a listing (kind 32267), a release (30063) and an asset (3063) that pins the image by digest. Orchard installs only an image whose digest matches the signed asset. The listing comes from desktopApp/packaging/orchard/com.vitorpamplona.amethyst.Amethyst.publish.toml.

It shares the key and the event kinds with Zapstore, so the two must never sign the same app id: the Flatpak is com.vitorpamplona.amethyst.Amethyst, Zapstore lists the APK as com.vitorpamplona.amethyst. Keep author-name and nip05 out of the config: either makes the tool sign a kind 0 that replaces Amethyst's Nostr profile.

One-time setup.

  • Let your gh login push to ghcr: gh auth refresh -s write:packages. The image goes to ghcr.io/vitorpamplona/amethyst-flatpak. ghcr packages start private and Orchard cannot pull a private one; this one was made public after its first push (v1.19.0). A new package would need the same.
  • 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. Don't use the tool's --prove: it commits that file itself.

Each release, after the GH Release assets exist, from the repo root on any machine with Docker, macOS included:

desktopApp/packaging/orchard/docker/publish-orchard.sh dry    # throwaway key; nothing leaves
desktopApp/packaging/orchard/docker/publish-orchard.sh live

ostree and flatpak, which unpack the release bundles, only exist on Linux, so the script runs everything in a container (desktopApp/packaging/orchard/docker/): it builds orchard-publish from Orchard's pinned tag (several minutes the first time, cached after), downloads both linux-*.flatpak assets of the version in gradle/libs.versions.toml into build/orchard/, and publishes both arches. live first checks that the key's npub is the one orchard.txt names and refuses to push or send anything otherwise. It reads the nsec from ORCHARD_NSEC, or from ~/.orchard-nsec; create that file in your own shell (the script's header has the zsh and bash lines) and delete it afterwards. The log lands in build/orchard/live.log.

By hand on Linux, without Docker: build the tool at a tag and keep the binary (Rust; rust-toolchain.toml pins the toolchain):

git clone --branch v0.1.2 https://github.com/Letdown2491/orchard
cargo build --release -p orchard-publish --manifest-path orchard/Cargo.toml
cp orchard/target/release/orchard-publish ~/.local/bin/   # anywhere on PATH

Then, each release, run it once per arch; both runs sign the same listing. The tree comes straight out of the release bundle with ostree, so nothing is rebuilt and no runtime is installed:

VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | cut -d'"' -f2)
DIR=desktopApp/packaging/orchard
read -rsp "Amethyst nsec: " ORCHARD_NSEC; echo      # prompted, so it stays out of shell history
export ORCHARD_NSEC ORCHARD_PUBLISH_VERSION="$VER"
export ORCHARD_REGISTRY_USER=$(gh api user -q .login) ORCHARD_REGISTRY_PASSWORD=$(gh auth token)

for ARCH in x64:x86_64 arm64:aarch64; do
  GH=${ARCH%%:*}; FP=${ARCH##*:}
  gh release download "v$VER" -p "amethyst-desktop-$VER-linux-$GH.flatpak" -O /tmp/amethyst.flatpak --clobber
  rm -rf /tmp/orchard-repo "$DIR/tree"
  ostree init --repo=/tmp/orchard-repo --mode=archive-z2
  flatpak build-import-bundle /tmp/orchard-repo /tmp/amethyst.flatpak
  ostree checkout --repo=/tmp/orchard-repo -U "app/com.vitorpamplona.amethyst.Amethyst/$FP/master" "$DIR/tree"
  orchard-publish "$DIR/com.vitorpamplona.amethyst.Amethyst.publish.toml" \
    --arch "linux-$FP" \
    --registry ghcr.io/vitorpamplona/amethyst-flatpak \
    --tag "latest-linux-$FP"
done
unset ORCHARD_NSEC ORCHARD_REGISTRY_PASSWORD

flatpak build-import-bundle may print a harmless GLib-CRITICAL line. The tool checks the registry serves the digest it built before it signs anything. Try a run first with --skip-push --skip-relays (and no ORCHARD_NSEC, which makes it use a throwaway key) to see the build without publishing. When the freedesktop runtime moves, update runtime in the config along with the Flatpak manifest.

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:

  1. 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/):
    desktopApp/packaging/flatpak/flathub/tools/regenerate-gradle-sources.sh vX.Y.Z
    
    It rewrites gradle-sources.json and prints the tag tarball's sha256.
  2. In flathub/com.vitorpamplona.amethyst.Amethyst.yml, point the archive source at the tag's URL and that sha256. When the JetBrains Runtime moves (JBR_BUILD in the regenerate script and the two jbrsdk sources), bump both and their sha256s together.
  3. Build and lint it locally (below), commit the three files here, then copy the manifest, flathub-offline.init.gradle and gradle-sources.json into 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:

  1. 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 by GITHUB_TOKEN, which is exactly how create-release.yml creates it. They now trigger on workflow_run after Create Release Assets succeeds, which also fixes a latent race — the old event fired while assets were still uploading.
  2. Nothing exists upstream to bump. brew bump-cask-pr and winget-releaser can 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.

  • play flavor → push via this server (Firebase/FCM).
  • fdroid flavor → 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 (see PULL_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 diff one-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.txt proof).
  • F-Droid: new version detected (may lag days).
  • Flathub (only once Amethyst is on Flathub): the app-repo PR with the regenerated gradle-sources.json is merged and Flathub's build is green.
  • Four sync PRs opened against this repo (amy formula, geode formula, amethyst-nostr cask, winget manifests) — merge them.
  • Homebrew: scripts/bump-homebrew-cask.sh vX.Y.Z passes — DMG verified and BrewTestBot's cask PR carries the same sha256 (no token; re-run later if the bot has not opened it yet). amy needs nothing: BrewTestBot bumps it.
  • Winget pushed upstream: scripts/bump-winget.sh vX.Y.Z (manual, no token — uses your gh auth).
  • 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 play build (only if the push contract changed — see § 4); UnifiedPush still works on an fdroid build.

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.