chore(release): bump to 1.15.1, unblocking the AAB on AGP 9.4.0

v1.15.0 was tagged but never shipped. Its `Create Release Assets` run failed in
`deploy-android` at the first packaging task:

    Execution failed for task ':amethyst:buildFdroidReleasePreBundle'
    > Entry name contains invalid characters:
      root/META-INF/zoomable-root:zoomable.kotlin_module

so no AAB, no APK, and none of the 47 assets were produced.

A `.kotlin_module` is named after the Gradle project path that produced it,
colons included. 14 of the 98 modules merged into the app carry one: zoomable,
Negentropy, vico, the seven coil3 artifacts, and four of ours -- Amethyst:quartz,
Amethyst:commons, Amethyst:quic and Amethyst:nestsClient -- so renaming our own
would not have been enough.

Bisected against the two toolchain bumps this cycle, since both landed after the
last good release: AGP 9.3.1 -> 9.4.0 and Kotlin 2.4.10 -> 2.4.20. With Kotlin
held at 2.4.20 and AGP reverted, both flavours' bundle tasks pass, so Kotlin is
not the trigger. The R8 output jar carries the identical 14 colon entries under
BOTH AGP versions -- 9.4.0 added the rejection rather than the names, in
JarFlinger.addJar, reached from PerModuleBundleTask.addHybridFolder.

`packaging.resources.excludes` was tried first and cannot work, at either
`META-INF/*.kotlin_module` or `**/*.kotlin_module`: with minification on, R8
emits the java resources itself and addHybridFolder hands JarFlinger its own
predicate, so those filters are never consulted. Confirmed by deleting the R8
output and re-running rather than reading a stale intermediate --
mergeJavaResource's jar holds zero kotlin_modules while R8's holds all 98.

So the entries are stripped from R8's jar in the moment before the bundle task
opens it, and the jar is put back exactly as R8 left it afterwards. Two details
carry their weight:

  - the strip is doFirst on the CONSUMER rather than doLast on R8, so a
    build-cache hit on R8 cannot skip it;
  - the restore is what keeps R8 up to date. Without it Gradle sees a modified
    output and re-runs R8 on every build -- measured here at ~2 min for an
    otherwise no-op build. With it, a second run reports
    minifyFdroidReleaseWithR8 UP-TO-DATE and finishes in 1s.

Pinning back to 9.3.1 was the alternative and is one line away; the catalog
comment records that, and says to drop the workaround when AGP fixes it.

Verified from a cleaned R8 output on both flavours:
buildFdroidReleasePreBundle and buildPlayReleasePreBundle both BUILD SUCCESSFUL,
each reporting "Stripped 14 colon-named entries from base.jar".

appCode 458 -> 459. RELEASE_NOTES_ID deliberately stays on the v1.15.0 note:
RELEASE_OPS has it repointed on x.y.0 only, and 1.15.1 ships that release's
contents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
This commit is contained in:
Vitor Pamplona
2026-09-11 18:42:22 -04:00
co-authored by Claude Opus 5
parent 9449bce038
commit a2f60eeeaf
10 changed files with 132 additions and 22 deletions
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.0` (Maven Central) **Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.1` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT **License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml ```toml
[versions] [versions]
quartz = "1.15.0" quartz = "1.15.1"
[libraries] [libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin ```kotlin
dependencies { dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.0") implementation("com.vitorpamplona.quartz:quartz:1.15.1")
} }
``` ```
@@ -3,7 +3,7 @@
## Current version ## Current version
``` ```
com.vitorpamplona.quartz:quartz:1.15.0 com.vitorpamplona.quartz:quartz:1.15.1
``` ```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml ```toml
[versions] [versions]
quartz = "1.15.0" quartz = "1.15.1"
[libraries] [libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin ```kotlin
// build.gradle.kts (app module) // build.gradle.kts (app module)
dependencies { dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.0") implementation("com.vitorpamplona.quartz:quartz:1.15.1")
} }
``` ```
@@ -70,7 +70,7 @@ plugins {
} }
dependencies { dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.0") implementation("com.vitorpamplona.quartz:quartz:1.15.1")
// JNA needed for libsodium (NIP-44) on JVM // JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1") implementation("net.java.dev.jna:jna:5.18.1")
} }
+3 -3
View File
@@ -533,7 +533,7 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time) ## Bootstrap runbook (one-time)
> **Status as of v1.15.0:** both Homebrew packages are now live upstream — the > **Status as of v1.15.1:** both Homebrew packages are now live upstream — the
> `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy` > `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy`
> formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so > formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so
> `bump-homebrew.yml` finally has something to bump. **Winget is still not > `bump-homebrew.yml` finally has something to bump. **Winget is still not
@@ -587,7 +587,7 @@ The token then lives only in that maintainer's shell:
```bash ```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.15.0 scripts/bump-homebrew-cask.sh v1.15.1
``` ```
Create one at Create one at
@@ -603,7 +603,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
runs fine from macOS or Linux: runs fine from macOS or Linux:
```bash ```bash
scripts/bump-winget.sh v1.15.0 scripts/bump-winget.sh v1.15.1
``` ```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
+5 -5
View File
@@ -328,16 +328,16 @@ repositories {
Add the following line to your `commonMain` dependencies: Add the following line to your `commonMain` dependencies:
```gradle ```gradle
implementation('com.vitorpamplona.quartz:quartz:1.15.0') implementation('com.vitorpamplona.quartz:quartz:1.15.1')
``` ```
Variations to each platform are also available: Variations to each platform are also available:
```gradle ```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.15.0') implementation('com.vitorpamplona.quartz:quartz-android:1.15.1')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.0') implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.1')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.0') implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.1')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.0') implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.1')
``` ```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz) Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+2 -2
View File
@@ -240,9 +240,9 @@ readable by anyone with push access here), so a maintainer runs the last step:
```bash ```bash
# after merging the sync PRs # after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.15.0 scripts/bump-homebrew-cask.sh v1.15.1
scripts/bump-winget.sh v1.15.0 # no token — uses your `gh` auth scripts/bump-winget.sh v1.15.1 # no token — uses your `gh` auth
``` ```
Both scripts re-verify the published artifact's sha256 before submitting, and Both scripts re-verify the published artifact's sha256 before submitting, and
+76
View File
@@ -1,3 +1,7 @@
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import org.gradle.api.services.BuildService import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters import org.gradle.api.services.BuildServiceParameters
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -611,3 +615,75 @@ dependencies {
implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions) implementation(libs.androidx.camera.extensions)
} }
// AGP 9.4.0's PerModuleBundleTask refuses to write an AAB entry whose name contains a colon:
//
// Entry name contains invalid characters: root/META-INF/zoomable-root:zoomable.kotlin_module
//
// A .kotlin_module is named after the Gradle project path that produced it, colons included, and
// 14 of the 98 merged into this app carry one -- zoomable, Negentropy, vico, seven coil3 artifacts
// and four of ours. The entries are identical under 9.3.1, which writes them without complaint, so
// 9.4.0 added the rejection rather than the names.
//
// packaging.resources.excludes cannot remove them: with minification on, R8 emits the java
// resources and PerModuleBundleTask.addHybridFolder hands JarFlinger its own predicate, so those
// filters are never consulted. Nothing in the AAB reads a .kotlin_module either -- it exists for
// the Kotlin compiler to resolve top-level declarations across modules at COMPILE time.
//
// So drop them from R8's java-res jar in the moment before the bundle task opens it, then put the
// jar back exactly as R8 left it. The strip is doFirst on the CONSUMER rather than doLast on R8, so
// a build-cache hit on R8 cannot skip it; the restore is what keeps R8 up to date next build --
// without it Gradle sees a modified output and re-runs R8 every time, which measured ~2 min a build
// here for no work.
fun stripColonNamedEntries(jar: File): Int {
val offenders = ZipFile(jar).use { zip -> zip.entries().toList().count { ':' in it.name } }
if (offenders == 0) return 0
val rewritten = File(jar.parentFile, "${jar.name}.stripped")
ZipFile(jar).use { zip ->
ZipOutputStream(rewritten.outputStream().buffered()).use { out ->
zip.entries().asSequence().filterNot { ':' in it.name }.forEach { entry ->
out.putNextEntry(ZipEntry(entry.name))
zip.getInputStream(entry).use { it.copyTo(out) }
out.closeEntry()
}
}
}
rewritten.copyTo(jar, overwrite = true)
rewritten.delete()
return offenders
}
androidComponents.onVariants { variant ->
val variantName = variant.name
val capitalized = variantName.replaceFirstChar { it.uppercase() }
tasks.matching { it.name == "build${capitalized}PreBundle" }.configureEach {
val javaResDir = layout.buildDirectory.dir("intermediates/merged_java_res/$variantName")
val backups = mutableMapOf<File, File>()
doFirst {
javaResDir.get().asFile
.walkTopDown()
.filter { it.isFile && it.extension == "jar" }
.forEach { jar ->
val backup = File(jar.parentFile, "${jar.name}.orig")
jar.copyTo(backup, overwrite = true)
val dropped = stripColonNamedEntries(jar)
if (dropped > 0) {
backups[jar] = backup
logger.lifecycle("Stripped $dropped colon-named entries from ${jar.name}")
} else {
backup.delete()
}
}
}
doLast {
backups.forEach { (jar, backup) ->
backup.copyTo(jar, overwrite = true)
backup.delete()
}
backups.clear()
}
}
}
+1
View File
@@ -2,6 +2,7 @@
Release notes for Amethyst, one file per version. Files are named with zero-padded version numbers so they sort correctly in any file browser. Use [`TEMPLATE.md`](TEMPLATE.md) as the starting point for the next release. Release notes for Amethyst, one file per version. Files are named with zero-padded version numbers so they sort correctly in any file browser. Use [`TEMPLATE.md`](TEMPLATE.md) as the starting point for the next release.
- [v1.15.1 — Release Build Fix](v1.15.01.md)
- [v1.15.0 — Marmot Updates, a Advanced Search Box, and Books, DVM Updates](v1.15.00.md) - [v1.15.0 — Marmot Updates, a Advanced Search Box, and Books, DVM Updates](v1.15.00.md)
- [v1.14.0 — Highlights, Relay Login, and a Much Faster Start](v1.14.00.md) - [v1.14.0 — Highlights, Relay Login, and a Much Faster Start](v1.14.00.md)
- [v1.13.1 — Follow-up Fixes for Buzz, Concord and the Keyboard](v1.13.01.md) - [v1.13.1 — Follow-up Fixes for Buzz, Concord and the Keyboard](v1.13.01.md)
+30
View File
@@ -0,0 +1,30 @@
# v1.15.1: Release Build Fix
v1.15.0 was tagged but never shipped: its `Create Release Assets` run failed in
`deploy-android` before producing a single artifact. This release carries the
whole of [v1.15.0](v1.15.00.md) plus the fix for that failure — read those notes
for what actually changed in the app.
## Build
- Strips colon-named `.kotlin_module` entries out of R8's java-resource jar
before the app bundle is written. AGP 9.4.0 refuses them:
```
Execution failed for task ':amethyst:buildFdroidReleasePreBundle'
> Entry name contains invalid characters: root/META-INF/zoomable-root:zoomable.kotlin_module
```
A `.kotlin_module` is named after the Gradle project path that produced it,
colons included, and 14 of the 98 modules merged into the app carry one —
zoomable, Negentropy, vico, seven coil3 artifacts, and four of ours
(`Amethyst:quartz`, `:commons`, `:quic`, `:nestsClient`). The entries are
byte-identical under AGP 9.3.1, which writes the bundle without complaint, so
9.4.0 added the rejection rather than the names.
`packaging.resources.excludes` cannot remove them — with minification on, R8
emits the java resources and `PerModuleBundleTask` hands `JarFlinger` its own
predicate, so those filters are never consulted. Nothing reads a
`.kotlin_module` out of an installed app either; the file exists for the
Kotlin compiler to resolve top-level declarations across modules at compile
time.
+3 -3
View File
@@ -32,7 +32,7 @@ docker run -d --name geode -p 7447:7447 \
``` ```
with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your
`geode.toml`. Pin a version (`:1.15.0`) instead of `:latest` for reproducible `geode.toml`. Pin a version (`:1.15.1`) instead of `:latest` for reproducible
deploys. To build the image yourself, from the repo root: deploys. To build the image yourself, from the repo root:
```bash ```bash
@@ -47,7 +47,7 @@ it — a minimal JRE is bundled, so no system Java is required. It installs to
`/opt/geode/` with the launcher at `/opt/geode/bin/geode`. `/opt/geode/` with the launcher at `/opt/geode/bin/geode`.
```bash ```bash
sudo dpkg -i geode-1.15.0-linux-x64.deb # or: sudo rpm -i geode-1.15.0-linux-x64.rpm sudo dpkg -i geode-1.15.1-linux-x64.deb # or: sudo rpm -i geode-1.15.1-linux-x64.rpm
``` ```
To run it as a managed service, wire up the shipped systemd unit To run it as a managed service, wire up the shipped systemd unit
@@ -69,7 +69,7 @@ formula: [`packaging/homebrew/geode-relay.rb`](packaging/homebrew/geode-relay.rb
Download `geode-<version>-<os>-<arch>.tar.gz`, unpack, and run: Download `geode-<version>-<os>-<arch>.tar.gz`, unpack, and run:
```bash ```bash
tar xzf geode-1.15.0-linux-x64.tar.gz tar xzf geode-1.15.1-linux-x64.tar.gz
./geode/bin/geode --config geode/share/geode/config.example.toml ./geode/bin/geode --config geode/share/geode/config.example.toml
``` ```
+5 -2
View File
@@ -2,8 +2,8 @@
# Amethyst app version — single source of truth consumed by both Android (amethyst/) # Amethyst app version — single source of truth consumed by both Android (amethyst/)
# and Desktop (desktopApp/). `appCode` is the Android versionCode: a monotonic # and Desktop (desktopApp/). `appCode` is the Android versionCode: a monotonic
# integer that must increment on every release, even when `app` is unchanged. # integer that must increment on every release, even when `app` is unchanged.
app = "1.15.0" app = "1.15.1"
appCode = "458" appCode = "459"
accompanistAdaptive = "0.37.3" accompanistAdaptive = "0.37.3"
# Pinned: 0.3.0 turns CacheMap.entries/keys/values into DeprecationLevel.ERROR (and # Pinned: 0.3.0 turns CacheMap.entries/keys/values into DeprecationLevel.ERROR (and
# throws UnsupportedOperationException at runtime), which breaks quartz's appleMain # throws UnsupportedOperationException at runtime), which breaks quartz's appleMain
@@ -13,6 +13,9 @@ accompanistAdaptive = "0.37.3"
cachemapVersion = "0.2.4" cachemapVersion = "0.2.4"
composeMultiplatform = "1.12.0" composeMultiplatform = "1.12.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
# 9.4.0's PerModuleBundleTask rejects an AAB entry whose name contains a colon, which every
# .kotlin_module named after a Gradle project path has. Worked around in amethyst/build.gradle.kts
# (stripColonNamedEntries) rather than by pinning to 9.3.1; drop that block if AGP fixes it.
agp = "9.4.0" agp = "9.4.0"
android-compileSdk = "37" android-compileSdk = "37"
android-minSdk = "26" android-minSdk = "26"