Mirrors the Homebrew cask change. The old design stored a classic `public_repo`
PAT as WINGET_TOKEN and handed it to the third-party
vedantmgoyal9/winget-releaser action: a token with write access to every public
repo the owning account can reach, given to code we do not control, in a place
any push-access collaborator could read it from (a pushed branch containing a
workflow runs with repo secrets).
- CI (bump-winget.yml, now "Sync Winget Manifest Reference") uses GITHUB_TOKEN
only: downloads the MSI, computes the sha256, reads the ProductCode from the
MSI Property table via msitools, and opens an in-repo PR syncing
desktopApp/packaging/winget/. Runs on ubuntu (1x billing) rather than Windows
since msitools reads the Property table fine.
- scripts/bump-winget.sh does the upstream PR. It needs NO new token: it drives
`gh`, which a maintainer already has authenticated, and it does not need
wingetcreate (Windows-only) because the manifests are plain YAML — so it runs
from macOS or Linux.
Add the three reference manifests under desktopApp/packaging/winget/, matching
the schema 1.12.0 shape used upstream. All three validate against Microsoft's
published JSON schemas.
Validation caught one real bug worth noting: an all-digit 64-char InstallerSha256
parses as a YAML *integer* and fails the schema's `string` type, so it is written
quoted. ProductCode is re-read every release because jpackage regenerates it per
build; it is the ARP key `winget upgrade` matches on.
Drops the last package-manager PAT from the secret inventory.
`brew bump-cask-pr` forks homebrew-cask into the token owner's account, which
requires a CLASSIC PAT with the `repo` scope. That scope cannot be narrowed: it
grants write to every repository the owning account can reach, including this
one. As an Actions secret it would be usable by anyone with push access here,
because a pushed branch containing a workflow runs with repo secrets — a strict
escalation for a channel that ships one DMG a month.
Split the work so the token never becomes a CI secret:
- CI (bump-homebrew.yml, now "Sync Homebrew Cask Reference") does the
error-prone bookkeeping with GITHUB_TOKEN only: downloads the DMG, asserts it
is notarized + stapled, computes the sha256, and opens an in-repo PR syncing
desktopApp/packaging/homebrew/amethyst-nostr.rb. This makes it a third sibling
of the amy/geode formula-sync workflows rather than a special case.
- scripts/bump-homebrew-cask.sh does the upstream PR from a maintainer's shell,
re-verifying sha256 and the notarization ticket against the live asset first,
and refusing to submit if either fails. Verified against v1.13.1: the sha256
matches and it correctly refuses on the missing notarization ticket.
Drop HOMEBREW_TOKEN from the secret inventory, and rewrite the two formula
TODO(bootstrap) notes that proposed reintroducing it so a future homebrew-core
submission follows the same local pattern.
The macOS leg ran only `packageReleaseDmg`, which SIGNS the DMG but does not
notarize it — notarization is a separate Compose task. The `notarization {}`
block in desktopApp/build.gradle.kts only supplies credentials; nothing invoked
it. So every release up to v1.13.1 shipped a signed but unnotarized DMG:
`xcrun stapler validate` reports no ticket on either the .app or the .dmg, and
Gatekeeper blocks it on first launch.
Append `:desktopApp:notarizeReleaseDmg` on the macOS leg. Compose 1.11.1's
AbstractNotarizationTask runs `notarytool submit --wait` and then `stapler
staple` in place, so the asset-collection step still finds the same file. Raise
that leg's inner timeout to 45m since the submit blocks on Apple.
Gate it on the cert AND all three notary secrets, so forks and credential-less
runs keep producing a plain unsigned DMG instead of failing.
Add a verify step that asserts the stapled ticket. The bug survived many
releases precisely because nothing ever checked the outcome.
Also update the reference cask to 1.13.1 and make it pass `brew audit --new
--cask` + `brew style` cleanly: add the missing conflicts_with (the tiling
window manager's cask installs the same Amethyst.app), add depends_on :macos,
fix stanza order, drop the unnecessary `verified:` (url and homepage share a
domain), and widen the zap to the state dirs the source actually uses.
Correct BUILDING.md's macOS state table, which listed a
com.vitorpamplona.amethyst.desktop.plist that does not exist and omitted
~/.amethyst. Note that Java's Preferences API writes to a SHARED
com.apple.java.util.prefs.plist, which is why the cask must not zap it.
bump-homebrew.yml pinned macauley/action-homebrew-bump-cask to
ad984534de44a9489a53aefd81eb77f87c70dc60, commented "v4.0.0". That SHA does not
exist — the action's only release is v1 (445c4239) from 2021. GitHub resolves
every `uses:` during "Set up job", so the job died before running a single step,
which no `if:` gate can avoid. It stayed invisible because the workflow had
never been triggered; fixing the trigger surfaced it immediately.
Call `brew bump-cask-pr` directly instead. It is the same command BUILDING.md
already documents for manual recovery, and it removes an unmaintained
third-party action that would hold a PAT with write access to homebrew-cask.
Split the workflow into check (ubuntu) + bump (macOS): cask commands are
macOS-only, but the bump is gated on a cask that does not exist yet, so the
10x-billed macOS job is only created once there is something to bump.
All four bump workflows triggered on `release: types: [released]`, which never
fires here: create-release.yml publishes the release with GITHUB_TOKEN, and
GitHub suppresses workflow-triggering events for GITHUB_TOKEN actions. They had
zero runs across every release up to v1.13.1.
Switch them to `workflow_run` on "Create Release Assets" completion, filtered to
a successful tag push. That also removes a latent race: `released` fired while
the matrix legs were still uploading assets, whereas workflow_run fires after
all of them finish.
The workflow_run payload carries no draft/prerelease flags, so add a
resolve-release composite action that reads them back from the API and feeds
assert-stable-release, keeping the defense-in-depth guard intact instead of
inferring stability from the tag string alone.
Also gate the cask/winget bumps on the package existing upstream. Neither
`amethyst-nostr` nor `VitorPamplona.Amethyst` has been bootstrapped, and
bump-cask-pr/winget-releaser can only update an existing package — without the
gate, fixing the trigger would file a spurious [release-ops] issue on every
release.
Docs: correct the claims this uncovered — Homebrew/Winget are not shipping,
macOS is arm64-only (no Intel DMG), the release carries 31 assets (13 Android,
not 12), Maven Central publishes from a step inside deploy-android and lags
repo1 by tens of minutes, RELEASE_NOTES_ID is minor-releases-only, and note the
git-credential-manager hang that blocks the release push.
The first cut appended :geode:test to the build-desktop matrix command.
That was wrong twice over: geode is JVM-only, so it ran 3× across the
ubuntu/macos/windows matrix, and — because org.gradle.parallel=true —
its default suite's CPU-heavy throughput benchmarks (a 1M-event mirror
sync, WireReqFloor, NegentropyServerReconcile) ran concurrently with the
timing-sensitive quartz relay-client tests, flaking
NostrClientReqBypassingRelayLimitsTest.denseSecondBeyondCapIsSteppedPastWithoutStalling.
Move :geode:test into its own test-geode job (needs: lint, ubuntu, JVM
21) so it runs once and its benchmark load can't starve another module's
timing assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
geode was runnable only via ./gradlew :geode:run and was absent from CI.
Give it the same release process as the amy CLI (it's the same kind of
application-plugin JVM module), plus the pieces a long-running server
daemon needs that a one-shot CLI does not.
- Main.kt: add terminal --version/-V and --help/-h flags so a packaged
binary has a fast, exit-0 command (Homebrew test block, package smoke
checks, Docker healthcheck).
- build.gradle.kts: jlinkRuntime + geodeImage (portable flat app-image
with a bundled JRE, plus config.example.toml + geode.service under
share/) + jpackageDeb/jpackageRpm, mirroring cli/. No Compose to
exclude — geode depends only on :quartz.
- Dockerfile + .dockerignore: multi-stage image (gradle installDist ->
temurin JRE), the primary channel for relay operators.
- packaging/: systemd unit, macOS hardened-runtime entitlements, and a
reference Homebrew formula.
- scripts/asset-name.sh: geode_asset_name/collect_geode_assets under the
canonical geode-<version>-<family>-<arch>.<ext> scheme.
- create-release.yml: build-geode matrix (tarball + deb/rpm + no-JRE jvm
bundle, with a serve+NIP-11 smoke test of the jlink image) and a
docker-geode job pushing ghcr.io/<owner>/geode:<version> (+ :latest).
- bump-homebrew-geode-formula.yml: auto-sync the reference formula on
stable releases.
- build.yml: run :geode:test in CI (it ran in no workflow before).
- README.md + plans/2026-07-24-geode-release.md: operator docs + design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
asadmansr/android-test-report-action@v1.2.0 is a Docker action whose
Dockerfile is `FROM ubuntu:18.04` + `apt-get install python` (Python 2).
Ubuntu 18.04 (bionic) is end-of-life, so its apt archives are now
unreliable and Python 2 has no installation candidate — the image rebuild
runs on every CI invocation, takes ~8 minutes, and has started hard-failing
the test-and-build-android job (`E: Package 'python' has no installation
candidate`). The action was last released in 2020 and is unmaintained, and
it was referenced by a movable tag rather than a pinned commit.
Swap it for mikepenz/action-junit-report (actively maintained, Apache-2.0,
JS action — no Docker rebuild), pinned to the v6.4.2 commit SHA. Use
annotate_only so it needs no `checks: write` permission and keeps working on
pull requests from forks; fail_on_failure keeps the job red when a unit test
fails, matching the previous step's behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMtpSuyr8FBh2ZQ22BV4ZP
The crowdin/github-action runs inside a Docker container as root, so files
and directories it downloads are root-owned. When Crowdin creates a brand-new
locale folder (e.g. values-en-rGB/ under commons), the unprivileged runner
user in the peter-evans/create-pull-request step can't unlink files inside it,
so the branch checkout aborts with 'unable to unlink ... Permission denied'.
Add a chown step that reclaims ownership of the whole working tree after the
Crowdin action and before any git manipulation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179E3AfsnB1NP87YmtAMZNX
The Crowdin action now downloads the commons module's Compose-resource
translations too, but the create-pull-request step's add-paths still only
covered amethyst/ and docs/. New untracked commons files created by the
Crowdin action (run as root in its container) were therefore left out of
the commit and shelved by git stash --include-untracked, which failed with
'Permission denied' when trying to remove the root-owned files.
Add commons/src/commonMain/composeResources/**/strings.xml to add-paths so
those translations are committed into the PR instead of being stashed.
The linux-portable release leg now wraps the createReleaseDistributable
tree it already builds into a single-file Flatpak bundle and attaches it
to the GH Release as amethyst-desktop-<version>-linux-x64.flatpak.
Packaging fixes to the existing (previously unwired) Flathub manifest:
- add the missing 512x512 icon (copy of desktopApp icon.png) and install
it under hicolor/512x512 instead of the never-present 256px path
- drop the openjdk module + sdk-extension: the jpackage tree bundles its
own trimmed JRE, so /app/jre was pure bloat
- grant --socket=x11 instead of wayland/fallback-x11: Compose Desktop
renders via AWT/skiko (X11-only on Linux, XWayland on Wayland), so
fallback-x11 left the app socketless on Wayland sessions
CI wiring:
- install flatpak tooling + the freedesktop runtime/sdk (version greped
from the manifest so the pin can't drift), retried like other fetches
- inject the AppStream <release> entry for the tagged version at build
time (the checked-in metainfo deliberately carries none)
- flatpak-builder with --disable-rofiles-fuse (GH runners) and a
--state-dir under desktopApp/build so the repo tree stays clean
- collect via the existing asset-name.sh contract (new flatpak ext)
Verified end-to-end locally: built the bundle from the manifest with a
stubbed jpackage tree, installed it, and ran the exported command inside
the sandbox (freedesktop 24.08, args forwarded through the wrapper).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4
Add bump-homebrew-formula.yml: on a stable release it downloads the published
amy-<version>-jvm.tar.gz bundle, computes its sha256, and opens a PR syncing
cli/packaging/homebrew/amy.rb's url + sha256 — automating the manual step the
formula header calls out and keeping the reference formula ready for the
homebrew-core submission. The homebrew-core auto-bump is deferred (documented
TODO) because brew bump-formula-pr can only bump a formula already in the tap,
and amy has not been submitted to homebrew-core yet.
Also fix the "Report failure" step in bump-homebrew.yml and bump-winget.yml:
both declared `permissions: contents: read`, but github.rest.issues.create
needs issues:write, so the failure reporter itself 403'd and never filed an
alert. Add issues:write to both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGdEUziwC3Uc3XhB7DFQYt
Accrescent only accepts a signed APK set of split APKs generated by
bundletool from an AAB — not the AAB itself and not a monolithic APK.
After signing the F-Droid AAB, run bundletool build-apks (--mode=default)
to emit dist/amethyst-fdroid-<tag>.apks, signed with the same release
keystore secret the other signing steps use. It is attached to the GitHub
Release via the existing dist/* glob.
Upload to Accrescent stays manual (drag the .apks into the developer
console): Accrescent has no publish API or CI CLI yet — both are on their
roadmap but unreleased. A build-time guard warns if the APK set exceeds
Accrescent's 128 MiB automated-check limit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rE4k6sUN39emsofSv1Y1M
Previously the Crowdin workflow ran two independent jobs that each opened
their own pull request: the crowdin/github-action sync PR ("New Crowdin
Translations") and the peter-evans seed PR ("Seed translator npub
placeholders").
Collapse them into a single job: the Crowdin action now only downloads
translations into the working tree (push_translations/create_pull_request
disabled), the seed script edits translators.json in the same checkout, and
one create-pull-request step opens a single combined PR on
l10n_crowdin_translations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kxm4Pq3rm4doVLqJ2LaXtr
v1.12.4 got the keychain right ("...in keychain [/Users/.../amethyst-signing
.keychain-db]") but createReleaseDistributable still failed with "Could not
find certificate". Different layer of the same problem:
Compose's MacSigner maps its identity to a cert by running `security
find-certificate -c <identity>`, prepending "Developer ID Application: " when
the identity doesn't already start with it. `codesign --sign` (used by the
signMacJarNatives task, which succeeds in the same job) instead matches a
SHA-1 hash OR any common-name substring. So a MAC_SIGN_IDENTITY secret that is
a fingerprint or a team-ID/partial name signs fine with codesign but, once
prefixed by Compose, is not a substring of the cert's common name -> zero
matches -> failure.
Reproduced locally against the real Developer ID cert:
find-certificate -c "Developer ID Application: <TEAMID>" -> 0 matches
find-certificate -c "Developer ID Application: <full CN>" -> 1 match
Fix: import-macos-cert now resolves the certificate's full "Developer ID
Application: NAME (TEAMID)" common name from the keychain (via find-identity)
and exposes it as an `identity` output. The desktop build feeds that to
Compose's signing.identity, falling back to the raw secret if resolution
fails. Independent of whatever form the secret takes. The amy CLI leg keeps
using bare codesign with the secret directly and is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop DMG release leg (build-desktop macos, packageReleaseDmg) has never
produced a signed artifact: createReleaseDistributable fails with "Could not
find certificate for '***' in keychain []". This is independent of the v1.12.3
notarization fix, which addressed the separate amy CLI leg.
Root cause: Compose's MacSignerImpl maps the signing identity to a certificate
by running `security find-certificate -a -c <identity>` with no keychain
argument. On the GitHub macOS runners that lookup does not resolve the cert that
import-macos-cert imported into a throwaway keychain and added only to the user
search list — even though bare `codesign --sign` (e.g. the signMacJarNatives
task, which succeeds in the same job) finds it fine. The "keychain []" in the
error is just the null settings.keychain being echoed.
Fix: export the throwaway keychain path from the import-macos-cert action and
feed it to Compose's `signing.keychain` via AMETHYST_MAC_SIGN_KEYCHAIN, so the
certificate lookup searches that keychain directly. Also set it as the default
keychain for good measure. No-op on local/PR builds (env unset -> Compose keeps
its previous default-search-list behavior).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks docs/changelog/translators.json into two lists maintained by
scripts/translators.sh:
- mappings: a forever-growing Crowdin-username/id -> npub registry. --seed
appends new contributors with a blank npub and never deletes or overwrites
existing entries.
- sinceLastTag: a rolling snapshot of who has translated since the last v* tag,
fully refreshed on every --seed run.
The contribution window now defaults to the most recent v* tag instead of a
fixed two months (falling back to two months ago when no tag is reachable). The
CI seed job fetches tags (fetch-depth: 0) so it can resolve that window.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
Adds a seed-translators job to the Crowdin workflow that runs
scripts/translators.sh --seed (past two months) and opens/updates a single
PR via peter-evans/create-pull-request whenever a new contributor appears, so
docs/changelog/translators.json stays current without manual upkeep. The
action is MIT and CI-only (not linked into any shipped artifact), and no-ops
when there is no diff.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
The v1.12.2 release was the first to actually codesign + notarize the macOS
artifacts (signing was wired after v1.12.1, which shipped unsigned). Both macOS
legs failed with "Notarization status: Invalid": Apple's notary service recurses
into the bundled jars and rejects the unsigned Mach-O natives inside them
(secp256k1, sqlite-bundled, jna, skiko, jkeychain, kdroidFilter mediaplayer) —
codesign on the .app and the CLI's loose-file loop never descend into jars.
Notary log confirmed the offending entries, e.g.
sqlite-bundled-jvm.jar/natives/osx_arm64/libsqliteJni.dylib
-> "not signed with a valid Developer ID certificate" / "no secure timestamp"
Add scripts/sign-macos-jar-natives.sh: a shared helper that signs every macOS
Mach-O inside the bundled jars with hardened runtime + a secure timestamp,
skipping Linux ELF via a `file` Mach-O gate and no-opping when no identity is
set (local/PR builds unchanged). Wire it into:
- the CLI notarize step (runs before the loose-file signing loop)
- a desktop signMacJarNatives Gradle task that signs the proguarded jars
between proguardReleaseJars and createReleaseDistributable, so Compose
seals already-signed code.
Validated locally on arm64: clean signed createReleaseDistributable produces an
.app that passes `codesign --verify --deep --strict`, with every nested native
carrying Developer ID + hardened runtime + secure timestamp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
amy is headless and compiles against zero Compose UI (the Compose deps are
`implementation` in :commons, so they never hit the CLI compile classpath),
but they still rode the runtime classpath into the shipped image — ~29 MB of
Compose desktop render stack, including skiko's native .dylibs that enlarged
the macOS notarization surface.
Exclude skiko + the org.jetbrains.compose UI groups (ui/foundation/material/
material3/animation) from :cli runtimeClasspath. Keep androidx.compose.runtime
(snapshot state + @Stable/@Immutable) — that IS CLI-safe and used by commons
models/state. This avoids the commons → commons/commons-ui module split: the
single-module, feature-cohesive design (commons/ARCHITECTURE.md §1/§3) is
preserved; only the runtime artifact is trimmed.
Result: amy image lib 77 MB -> 48 MB (-38%), and all 4 Compose/skiko notary
dylibs gone (only secp256k1/jna/sqlite natives remain — the ones actually
loaded). A create-release.yml assertion fails the build if the UI stack ever
leaks back.
Verified with the SDK hidden + an offline amy command battery (init/whoami/
--json/relay/marmot/login, plus a real 6-relay key-package round-trip): zero
NoClassDefFoundError/linkage errors; init derives a secp256k1 key cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
macOS validation (Developer ID D77MCV9NZ7) confirmed the hardened-runtime
entitlements are correct and load-bearing: amy init derives a secp256k1 key
cleanly, and dropping disable-library-validation reproduces the runtime
dlopen Team-ID failure. The one unverified gap is whether Apple's notary
service accepts the unsigned Mach-O dylibs embedded inside lib/*.jar
(secp256k1/jna/sqlite/skiko), which it inspects recursively.
- create-release.yml: the notarize step now submits with --output-format json,
and on any non-Accepted status dumps `notarytool log` (per-file issues) and
fails — so the first real run names the offending files instead of failing
opaquely. No speculative in-jar signing yet; gather the log first.
- BUILDING.md: record the validation result, the embedded-jar-native risk, the
one-run way to decide it (workflow_dispatch dry_run with MAC_* secrets), and
the staged fixes (sign-in-jar and/or strip the skiko/Compose leak). Note the
desktop app shares the same jars and needs its own dry-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Sign the macOS jlink image (amy-<version>-macos-arm64.tar.gz) so users who
download it directly clear Gatekeeper. Reuses the same Developer ID cert and
the six MAC_* secrets as the desktop DMG; no-op when they're absent.
- .github/actions/import-macos-cert: factor the throwaway-keychain cert import
into a composite action; the desktop leg now uses it too (was inline).
- create-release.yml (build-cli macOS leg): import the cert, then codesign
every Mach-O binary in the bundled JRE (executables get hardened-runtime
entitlements, dylibs don't) and notarize via notarytool --wait. Runs before
the collect step so the tarred image is signed. Job timeout 30->45 min for
notarization headroom.
- cli/packaging/macos/amy.entitlements: hardened-runtime entitlements; the
disable-library-validation key lets the JVM load the secp256k1 native dylib
it extracts from a jar at runtime (would otherwise crash under notarization).
- BUILDING.md: document the tarball signing, the no-stapling/online-check
caveat, and that the Homebrew-core jvm bundle is intentionally left unsigned.
Untested end-to-end (no macOS runner / Apple creds here) — validate with a
workflow_dispatch dry-run once the secrets are provisioned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Enable distributing the `amy` CLI via Homebrew-core (mainline formulae).
Homebrew-core builds in a network-sandboxed env, so a from-source Gradle
build can't resolve Maven deps there; the accepted pattern for JVM tools is a
pre-built no-JRE jar bundle + `depends_on "openjdk"`. installDist already
produces exactly that (bin/amy + lib/*.jar, no bundled runtime).
- create-release.yml: publish `amy-<version>-jvm.tar.gz` (the installDist tree)
as a release asset on the linux leg. Pure JVM bytecode, so one
platform-independent artifact serves every OS.
- packaging/homebrew/amy.rb: reference formula (depends_on openjdk, livecheck
for BrewTestBot auto-bumps, `amy --help` smoke test). Not consumed by any
build here — it's the artifact to submit to Homebrew/homebrew-core.
- BUILDING.md: homebrew-core submission runbook; note that the desktop app is
already on mainline Homebrew (homebrew/cask); document name-collision and
pre-built-jar review caveats.
- asset-name.sh: document the jvm bundle naming exception.
Verified locally: :cli:installDist builds with only a JDK (no Android SDK),
and the extracted bundle runs via JAVA_HOME (`amy --help` exits 0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Add gated code-signing + notarization for the macOS desktop DMG so it can
clear Gatekeeper and stay in Homebrew's main cask (unsigned casks are
rejected after 2026-09-01).
- desktopApp/build.gradle.kts: macOS signing{}/notarization{} blocks, gated
on the AMETHYST_MAC_SIGN_IDENTITY env var. Absent => unsigned DMG, exactly
as before, so local dev and PR CI are unaffected.
- create-release.yml: import a Developer ID cert into a throwaway keychain on
the macOS leg and export the signing/notary env. Soft-gated on the
MAC_CERTIFICATE_P12 secret — no secret => unsigned build.
- BUILDING.md: document the six MAC_* secrets, how to generate them, and flip
the unsigned-cask fallback note to reflect the wiring is now in place
(pending Apple credentials).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Correctness fixes in GlobalMediaPlayer.kt
- snapshotFlow { hasMedia } collector for initial seek used `return@collect`
which only exits the lambda; the collector kept running and each
subsequent playVideo() call accumulated a live collector that would
re-fire a stale seekTo() on the wrong media. Replaced with `Flow.first`
which terminates the collection cleanly.
- playVideo()/playAudio() reset the public MediaPlaybackState to
volume=100/isMuted=false on a new URL, but the kdroidFilter player
retains its `volume` across openUri(); muting one track and starting a
new one left the engine silent while the UI showed unmuted. Reset
`player.volume = 1f` to match the public state.
- ensureVideoPlayer()/ensureAudioPlayer() called createVideoPlayerState()
synchronously from the Compose getter; if native init throws (missing
GStreamer on Linux, broken NativeLibraryLoader extraction) the whole
window would crash. Wrapped in runCatching and changed
activeVideoPlayerState to nullable. Consumers in DesktopVideoPlayer
and GlobalFullscreenOverlay handle the null path by rendering the
thumbnail / blank backdrop respectively; playVideo()/playAudio()
surface "Video playback unavailable" through the existing
errorReason -> PlaybackErrorMessage path.
Crash mitigation (kdroidFilter 0.10.0 UAF in MacVideoPlayerSurface)
- NowPlayingBar previously mounted a SECOND VideoPlayerSurface against
the same VideoPlayerState while the feed card was already mounting
one, doubling the draw rate against the shared frame bitmap and
widening the UAF window in MacVideoPlayerSurface's RasterFromBitmap
path. Mini-preview now renders the cached thumbnail (or the music
icon fallback). 0.10.1 contains an upstream fix
("recover video playback after composition removal") but is not yet
on Maven Central — single-surface mounting is the only mitigation
we can ship today.
VideoThumbnailCache.kt
- Truncated-download cache poisoning: when an origin ignored the
Range: header and returned HTTP 200 with the full body, we capped
the copy at MAX_THUMB_BYTES and persisted the truncated file
forever. Subsequent thumbnail attempts hit the broken cache file
and re-failed JCodec/ffmpeg every time. Tag download results with
whether the server actually returned 206; on 200, extract from the
temp file and delete it (no persistent cache hit).
- Tor bypass: replaced the bare OkHttpClient with
DesktopHttpClient.currentClient() so thumbnail fetches respect the
user's Tor preference (fail-closed when Tor is expected but
bootstrapping).
- ffmpeg version probe leaked the process on hang: now drains stdout
to DISCARD and calls destroyForcibly() on timeout.
- Frame-extract ffmpeg subprocess could deadlock on a chatty stderr
pipe: redirectError(DISCARD) so we never wait on stderr; a finally
block destroys the process if anything leaked through the timeout.
CI workflow cleanup
- Removed vlc-setup download cache + pre-fetch steps from
build.yml and smoke-test-desktop.yml. They were targeting an
ir.mahozad.vlc-setup plugin we no longer apply, so they wasted
~minutes of CI time per leg and tied the build to videolan.org
reachability for no reason.
- Trimmed create-release.yml's stale VLC-plugins justification on
the linuxdeploy-vs-appimagetool comment.
.gitignore + missing per-OS ffmpeg READMEs
- The pre-PR rules blanket-ignored desktopApp/src/jvmMain/appResources/{linux,macos,windows}/
so the LGPL FFmpeg drop-in slot READMEs created in 704f4f44e never
reached the commit. Refined the ignore rules to keep stale vlc/ workspace
trees out of git (still ignored) while explicitly tracking the
ffmpeg/README.md drop-in slot under each OS. The READMEs document the
recommended LGPL build source per OS for the bundled-FFmpeg packaging
path.
Verified on macOS arm64:
./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL
./gradlew :desktopApp:test BUILD SUCCESSFUL
./gradlew :desktopApp:spotlessApply clean
Refs PR #3175 review by @davotoula.
The test-quartz-ios job only compiled :commons production code for iOS
(compileKotlinIos*), never the commonTest source set, so a test using a
JVM-only API compiled fine on JVM/Android and shipped green while silently
breaking compileTestKotlinIosSimulatorArm64.
Mirror the quartz step: run :commons:iosSimulatorArm64Test (compiles +
runs the shared commonTest suite on the simulator) and
:commons:compileTestKotlinIosArm64 (device-only compile drift). This keeps
commonTest KMP-clean going forward.
Zulu's CDN (cdn.azul.com) has been returning HTTP 520 for several
hours, breaking JDK 21 setup across multiple PRs (#3075, #3095) on
every Linux runner. actions/setup-java's internal retry budget isn't
enough to ride through it.
Temurin (Adoptium) publishes its JDKs as GitHub Releases assets, so
the download path uses GitHub's own CDN — completely independent
infrastructure. Java 21 is the same upstream OpenJDK build either way,
so Gradle, Compose Multiplatform, and jpackage behave identically.
Applied to all setup-java steps in build.yml, smoke-test-desktop.yml,
and create-release.yml.
Phase 2 task 10 of the iOS plan — flip on iOS targets for :commons.
Gradle dep resolution is fully green for iOS; actual Kotlin/Native
compilation runs on the macOS CI job (the dev container in which this
was authored can't extract the K/N LLVM toolchain).
Dep reshuffle to match what's actually KMP-available:
- commonMain: kept project(":quartz"), Compose Multiplatform, coil-compose,
androidx-collection, kotlinx-collections-immutable, kotlinx-serialization-json,
compose components-resources, androidx-lifecycle-viewmodel,
androidx-lifecycle-runtime-compose. These all publish iosArm64 +
iosSimulatorArm64 variants per `.module` inspection.
- jvmAndroid (NEW location): project(":nestsClient") (JVM+Android-only),
coil-okhttp (JVM-only), markdown-commonmark / markdown-ui /
markdown-ui-material3 (the RenderMarkdown.kt consumer is already in
jvmAndroid), and androidx-lifecycle-viewmodel-compose (AndroidX publishes
android + jvmStubs + linuxx64Stubs variants — no iOS, so the viewModel()
Composable helper stays JVM-bound until we either swap to the
org.jetbrains.androidx.lifecycle variant or accept a platform-specific
ViewModel access pattern on iOS).
- libs.versions.toml: adds androidx-lifecycle-viewmodel catalog entry.
- New intermediate source set iosMain → both iosArm64Main and
iosSimulatorArm64Main depend on it (clean place for iOS-only actuals
when KmpLock, WeakReference, etc. get their iOS implementations).
- .github/workflows/build.yml: test-quartz-ios job now also runs
:commons:compileKotlinIosArm64 + :commons:compileKotlinIosSimulatorArm64.
The one Jackson holdout in commons/commonMain. Migrating it unblocks
the iOS purity gate for :commons (Phase 1 of the iOS plan).
- Rewrites FeedDefinitionSerializer with the kotlinx.serialization JSON
tree API (JsonObject / JsonArray / JsonPrimitive). Wire format is
byte-identical, so users' existing on-disk custom-feed definitions
keep deserializing — covered by a new parsesLegacyJacksonOutput test
that pins a hand-written Jackson-shaped JSON blob.
- Adds :commons:verifyKmpPurity (mirrors the one in :quartz) and wires
it into the CI lint job alongside :quartz:verifyKmpPurity.
- Pulls in kotlinx-serialization-json as a commonMain dep; the
serialization plugin was already applied on :commons.
Phase 1 of the iOS support plan (amethyst/plans/2026-05-24-ios-support.md).
Two independent guards so JVM-only imports can't silently appear in
quartz's iOS-bound source sets:
- :quartz:verifyKmpPurity (Linux, ~1s): scans commonMain + apple/native
source sets for com.fasterxml.jackson / okhttp3 references and fails
the build with a clear pointer to the offending file:line. Wired into
the existing lint job so it runs on every PR.
- test-quartz-ios (macos-latest): runs :quartz:iosSimulatorArm64Test on
the simulator (NIP-04, NIP-17, NIP-19, NIP-49 vectors + AES-GCM and
chatroom-key tests already in quartz/src/iosTest) and additionally
compileTestKotlinIosArm64 to catch device-variant compile drift.
The smoke test discovered that ProGuard's method/marking/static pass
converts kmp-tor's AsyncFs.of() from instance to static. The JVM
then throws IncompatibleClassChangeError at launch because callers
still use invokevirtual.
Also updates smoke-test-desktop.yml trigger: runs on any PR touching
desktopApp/ (not just build config files).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dpkg -i exits non-zero when the post-install script fails even with
--force-all. Allow the error with || true, then verify the binary
was actually extracted to /opt before proceeding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jpackage's post-install script calls xdg-desktop-menu which fails on
GitHub Actions runners with "No writable system menu directory found".
Menu registration is irrelevant for smoke testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes#2819 — v1.08.0 .deb crashed on Ubuntu with ManagementFactory
error because the build had no jlink modules() declaration. Current
main already has the fix; this PR adds CI to prevent regressions.
- Add compose.desktop.uiTestJUnit4 dependency
- DesktopLaunchSmokeTest: renders LoginScreen, asserts title text
- build.yml: xvfb for Linux leg so UI test runs on every PR
- smoke-test-desktop.yml: builds release .deb, installs it, launches
under xvfb, verifies process stays alive 10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
linuxdeploy auto-walks every binary in the staged AppDir with ldd to
bundle their shared-library deps. That fights jpackage's self-contained
output on two axes and aborts the createReleaseAppImage task before any
AppImage is produced:
- The bundled JRE puts libjvm.so at usr/lib/runtime/lib/server/, while
its sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so
and others in usr/lib/runtime/lib/) have RPATH=$ORIGIN. ldd cannot
resolve libjvm.so from those libs without help, so linuxdeploy errors:
ERROR: Could not find dependency: libjvm.so
ERROR: Failed to deploy dependencies for existing files
- The bundled libvlc plugins under usr/lib/app/resources/vlc/ are
UPX-compressed by the vlc-setup plugin. patchelf cannot read their
section headers, so linuxdeploy aborts:
ERROR: Call to patchelf failed:
patchelf: no section headers. The input file is probably a
statically linked, self-decompressing binary
- Even after working around libjvm.so via LD_LIBRARY_PATH, several VLC
libs have RUNPATH pointing at the wrong directory, so ldd fails on
libva.so.2 / libvlccore.so.9 next.
This is why v1.09.2 shipped .deb + .rpm from the linux leg but no
.AppImage and no linux .tar.gz from the linux-portable leg — the gradle
step failed on linuxdeploy, so the subsequent "Build portable archives"
step never ran.
Swap to appimagetool, which only embeds the AppDir as-is into a
SquashFS-backed, runtime-prepended AppImage and never touches the
contents. jpackage already bundles a complete, self-contained app —
we don't need linuxdeploy's dep-discovery. AppRun continues to handle
LD_LIBRARY_PATH at launch.
Verified locally on ubuntu-24.04: a fresh
:desktopApp:createReleaseAppImage now produces a valid 254 MB
Amethyst-1.09.2-x86_64.AppImage in ~30 s on top of an up-to-date
createReleaseDistributable.
Side changes:
- Workflow installs desktop-file-utils (appimagetool 1.9.0 calls
desktop-file-validate on the .desktop entry).
- BUILDING.md updated with the new local-dev fetch command.
- .gitignore replaces the linuxdeploy paths with appimagetool's.
GitHub's macos-13 runner image is being retired. Drop the x64 macOS
legs from both the desktop and CLI release matrices; macos-14 arm64
continues to ship the macOS builds.
The :ammolite module contained no production Kotlin/Java sources (just
a manifest, build.gradle, and proguard stubs) and no module in the
codebase imports com.vitorpamplona.ammolite.*.
Removes:
- ammolite/ directory (5 files)
- :ammolite project include in settings.gradle
- implementation project(':ammolite') from :amethyst
- androidTestImplementation project(':ammolite') from :benchmark
- :ammolite:testDebugUnitTest from CI workflow and pre-push hook
- -keep class com.vitorpamplona.ammolite.** rules from
:amethyst, :commons, and :desktopApp proguard files
- Stale references in CONTRIBUTING.md, CLAUDE.md, and the
gradle-expert skill dependency-graph doc
Small build-graph win: one fewer module to configure, compile, lint,
and spotless-check on every build, and one fewer unit-test target in
both CI and the local pre-push hook.
The AppImage build inputs (AppRun, .desktop entry, icon, and the
CI-fetched linuxdeploy binary) are consumed only by desktopApp's
createReleaseAppImage task. Co-locating them under
desktopApp/packaging/appimage/ removes the `../` path escape from the
build script and keeps all desktop packaging assets inside the module.
https://claude.ai/code/session_0137ULcfJkASmfmffFBdW8ac
Companion to CONTRIBUTING.md targeting external PRs whose diff was
substantially authored by an AI coding assistant. Adds five gates the
existing doc does not cover:
- Research before code (issue still valid, decentralisation fit)
- Build and install both flavours (Play + F-Droid)
- Performance and resource hygiene (main-thread, coroutines,
LocalCache reuse, relay subscriptions, KMP source sets, lambda Log)
- Regression test plan alongside feature test plan
- Don't-touch list (signer/KeyStore, release pipeline, NIP direction)
Filename deliberately avoids AGENTS.md to keep internal dev-team
agents unaffected by auto-load conventions. CONTRIBUTING.md and the
PR template each gain a one-line pointer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Linux build path of the vlc-setup plugin pulls vlc-plugins-linux from
Maven Central (ir.mahozad:vlc-plugins-linux), which only ships 3.0.20 and
3.0.20-2 — there is no 3.0.21 artifact yet. With vlcVersion set to 3.0.21,
both the CI pre-fetch step and the in-Gradle vlcDownload task hit a 404.
Match VLC_VERSION in build.yml and document the lag so future bumps wait
for the Maven artifact to catch up.
https://claude.ai/code/session_01GrZLMi3sdp6frwREmQ9cUi
Replace the nick-fields/retry wrapper around the Gradle invocation with a
curl-based pre-fetch step. The vlc-setup plugin writes its downloads to
${gradleUserHomeDir}/vlcSetup/ with overwrite(false), so dropping the
archives there ahead of time turns vlcDownload / upxDownload into no-ops.
curl --retry-all-errors --retry-max-time 900 tolerates a sustained
get.videolan.org outage far better than the plugin's de.undercouch
Download (retries(4) + 5min readTimeout, which still hit
SocketTimeoutException on Windows).
The actions/cache step at ~/.gradle/vlcSetup still captures the result
for subsequent runs; the prefetch only does real work on cache miss.
The in-build retries(4) in desktopApp/build.gradle.kts stays as a third
line of defense.
URLs and on-disk paths are read straight from the plugin source
(ir.mahozad.vlc-setup 0.1.0); macOS skips UPX because UPX cannot compress
.dylib files.
The Windows leg of the Test/Build workflow has been intermittently failing
with SocketTimeoutException inside :desktopApp:vlcDownload, even after the
in-build retry budget (retries(4) + 5min readTimeout in
desktopApp/build.gradle.kts) and the actions/cache of ~/.gradle/vlcSetup.
When get.videolan.org is unreachable for long enough to exhaust the inner
retries, the whole job dies on a cache miss.
Wrap the Test+Build step in nick-fields/retry@v4 (max_attempts: 2,
timeout_minutes: 45) so one outer retry can ride out a sustained
videolan.org outage. This mirrors the proven pattern already used in
create-release.yml for the release artifact build.