Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/kotlin-sdk/PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Publishing `org.dashj:dash-sdk-android` to Maven Central

The SDK publishes under the existing **`org.dashj`** namespace (same as
`dashj-core`), so no new Central namespace verification is needed. This is a
Maven-coordinates decision only — the Kotlin source packages remain
`org.dashfoundation.dashsdk`. Deployment uses **JReleaser**, mirroring
[dashj/core/build.gradle](https://github.com/dashpay/dashj/blob/master/core/build.gradle):
release versions go to Maven Central via the Central Portal; `-SNAPSHOT`
versions go to the Sonatype snapshots repository.

## Prerequisites

- **Sonatype Central Portal token** for the `org.dashj` namespace (held by
HashEngineering — the same account that publishes `dashj-core`).
- **GPG signing key** (the dashj release key works), with the public key on the
keyservers.
- **Native toolchain**: Android NDK r28+ (`28.1.13356709`), `cargo-ndk`, rustup
targets `aarch64-linux-android` and `x86_64-linux-android`, JDK 17.

## Credentials — environment only, never committed

Gradle signing (signs artifacts during the staging publish; the classic
`signing.keyId` / `signing.password` / `signing.secretKeyRingFile` entries in
`~/.gradle/gradle.properties`, as used for dashj-core, also work):

```bash
export ORG_GRADLE_PROJECT_signingKey="$(cat private-key.asc)" # ASCII-armored
export ORG_GRADLE_PROJECT_signingPassword="…"
```

JReleaser deploy (read only when a `jreleaser*` task runs):

```bash
export JRELEASER_MAVENCENTRAL_SONATYPE_USERNAME="…" # Central Portal token name
export JRELEASER_MAVENCENTRAL_SONATYPE_PASSWORD="…" # Central Portal token value
export JRELEASER_NEXUS2_SNAPSHOTS_USERNAME="…" # same token (snapshots repo)
export JRELEASER_NEXUS2_SNAPSHOTS_PASSWORD="…"
export JRELEASER_GPG_PUBLIC_KEY="$(cat public-key.asc)"
export JRELEASER_GPG_SECRET_KEY="$(cat private-key.asc)"
export JRELEASER_GPG_PASSPHRASE="…"
```

## Release steps (from `packages/kotlin-sdk/`)

```bash
# 1. Build the native JNI library (arm64-v8a + x86_64) and verify its exports.
./build_android.sh --verify

# 2. Stage the signed release AAR + sources/javadoc jars into sdk/build/staging-deploy.
./gradlew :sdk:publishReleasePublicationToStagingRepository -PsdkVersion=0.1.0
# (the staging dir sdk/build/staging-deploy is wiped automatically before every
# staging publish — cleanStagingDeploy — so stale versions from earlier runs
# can never reach JReleaser)

# 3. Upload the staged artifacts. Release versions -> Maven Central;
# -SNAPSHOT versions -> Sonatype snapshots.
./gradlew :sdk:jreleaserDeploy -PsdkVersion=0.1.0
```

Then approve/verify the deployment at <https://central.sonatype.com> and
confirm the artifact appears on search.maven.org (sync takes ~30 min).

**Versioning:** the default is `0.1.0-SNAPSHOT`; pass the same
`-PsdkVersion=x.y.z` to steps 2 and 3. The first release should be a real
version (e.g. `0.1.0`) — the Android wallet's CI must depend on a pinned
release, never a snapshot.

## The JNI guard

`sdk/src/main/jniLibs/` is gitignored, so a clean checkout has no
`libdash_sdk_jni.so` — and an AAR published without it installs fine but dies
at runtime with `UnsatisfiedLinkError`. The `verifyJniLibsForRemotePublish`
task therefore **hard-fails** every staging/remote/jreleaser publish task when
the `.so` is missing for `arm64-v8a` or `x86_64`. There is no override for
remote publishes; `-PallowMissingJni` only downgrades the check for
`publishToMavenLocal` (metadata-only dry runs into `~/.m2`). If the guard
fires, run step 1.

## Consuming the artifact

```kotlin
repositories { mavenCentral() }
dependencies { implementation("org.dashj:dash-sdk-android:0.1.0") }
```

Requires `minSdk 29`. The AAR bundles the native library for `arm64-v8a` and
`x86_64`; `armeabi-v7a` is intentionally unsupported.
2 changes: 2 additions & 0 deletions packages/kotlin-sdk/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ androidxTestExtJunit = "1.2.1"
androidxTestRunner = "1.6.2"
espresso = "3.6.1"
robolectric = "4.14.1"
jreleaser = "1.17.0"

[libraries]
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
Expand Down Expand Up @@ -66,3 +67,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
jreleaser = { id = "org.jreleaser", version.ref = "jreleaser" }
261 changes: 260 additions & 1 deletion packages/kotlin-sdk/sdk/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import java.util.concurrent.Callable
import org.gradle.api.publish.maven.tasks.PublishToMavenLocal
import org.gradle.api.publish.maven.tasks.PublishToMavenRepository
import org.jreleaser.model.Active

plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
`maven-publish`
id("signing")
alias(libs.plugins.jreleaser)
}

// Published Maven coordinate group. Per HashEngineering's call on PR #4045:
// publish under the existing `org.dashj` group — Dash already owns dashj.org and
// ships its legacy Core/Platform SDKs there, so this needs no separate domain
// verification for Maven Central (`org.dashfoundation` would have, since
// dashfoundation.org is held by a third party). The Kotlin source packages stay
// `org.dashfoundation.dashsdk`; this is a Maven-coordinates decision only.
// `maven-publish` handles local / internal-repo publishing; jreleaser (bottom of
// file) handles Maven Central releases and Sonatype SNAPSHOTs, mirroring
// dashj/core/build.gradle.
group = "org.dashj"
version = project.findProperty("sdkVersion")?.toString() ?: "0.1.0-SNAPSHOT"

android {
namespace = "org.dashfoundation.dashsdk"
compileSdk = 35
Expand Down Expand Up @@ -35,6 +55,18 @@ android {
// libdash_sdk_jni.so is produced by ../build_android.sh (cargo-ndk) into
// src/main/jniLibs/<abi>/ — AGP packages it from there. NDK r28 emits
// 16 KB-aligned ELF segments by default (Android 15+ requirement).
publishing {
singleVariant("release") {
withSourcesJar()
// Maven Central's Publisher Portal rejects any non-POM component that
// lacks a javadoc jar, and the snapshot deployer sets
// applyMavenCentralRules(true) which enforces it before upload
// (dashpay/platform#4045). AGP emits a (Kotlin-appropriate) javadoc jar
// here — no Dokka dependency required.
withJavadocJar()
}
}

packaging {
jniLibs {
useLegacyPackaging = false
Expand All @@ -59,7 +91,11 @@ ksp {
}

dependencies {
implementation(libs.kotlinx.coroutines.core)
// `api`, not `implementation`: the public SDK surface returns coroutines
// types (Room DAO `Flow<…>`, `PlatformWalletManager.pendingIdentityKeys:
// StateFlow<…>`, etc.), so a consumer of the published coordinate needs
// coroutines-core on its own compile classpath.
api(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)

Expand All @@ -83,3 +119,226 @@ dependencies {
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.room.testing)
}

// Publishes the release AAR (incl. libdash_sdk_jni.so if ../build_android.sh ran first) with a
// POM carrying the Room/DataStore/Biometric api dependencies.
// Local: ./gradlew :sdk:publishToMavenLocal Coordinates: org.dashj:dash-sdk-android:<version>
// Remote: `:sdk:publishReleasePublicationToStagingRepository` stages the signed
// artifacts into build/staging-deploy, then `:sdk:jreleaserDeploy` uploads
// them to Maven Central (release) / Sonatype snapshots (see jreleaser block).
// Override the version with -PsdkVersion=x.y.z (defaults to 0.1.0-SNAPSHOT).
afterEvaluate {
publishing {
repositories {
// Local staging dir that jreleaser reads from and uploads (below); it is
// not itself a network repo, so staging never needs credentials.
maven {
name = "staging"
url = uri(layout.buildDirectory.dir("staging-deploy"))
}
}

// The staging dir is reused across runs and maven-publish never removes
// other versions' directories — a release deploy after an earlier
// snapshot/release staging run would hand JReleaser stale coordinates.
// Wipe it before every staging publish so each deploy starts clean.
val cleanStagingDeploy = tasks.register<Delete>("cleanStagingDeploy") {
delete(layout.buildDirectory.dir("staging-deploy"))
}
tasks.withType<PublishToMavenRepository>().configureEach {
if (name.endsWith("ToStagingRepository")) {
dependsOn(cleanStagingDeploy)
}
}
publications {
create<MavenPublication>("release") {
from(components["release"])
groupId = "org.dashj"
artifactId = "dash-sdk-android"
pom {
name.set("Dash Platform Kotlin SDK")
description.set(
"Kotlin SDK for Dash Core (L1 SPV) and Dash Platform " +
"(identities, DPNS, DashPay, shielded balances)"
)
url.set("https://github.com/dashpay/platform")
licenses {
license {
name.set("MIT License")
url.set("https://github.com/dashpay/platform/blob/master/LICENSE")
}
}
// scm + developers: required by Maven Central (and most
// remote repos) for POM validation. Pointed at the GitHub repo.
scm {
url.set("https://github.com/dashpay/platform")
connection.set("scm:git:git://github.com/dashpay/platform.git")
developerConnection.set(
"scm:git:ssh://git@github.com/dashpay/platform.git"
)
}
developers {
developer {
id.set("dashpay")
name.set("Dash Platform Contributors")
url.set("https://github.com/dashpay/platform")
}
}
}
}
}
}

// Sign published artifacts, but only for a real remote publish. `publishToMavenLocal`
// and a bare `assemble` never put a PublishToMavenRepository task in the graph, so a
// local build requires no GPG key. Keys come from the standard signing properties/env
// (signing.keyId + signing.password + signing.secretKeyRingFile, or the in-memory
// ORG_GRADLE_PROJECT_signingKey / signingPassword pair) when a remote publish runs.
signing {
// Wire the documented in-memory-key path: the ORG_GRADLE_PROJECT_signingKey
// / signingPassword env vars land as the Gradle project properties
// `signingKey` / `signingPassword`, but the signing plugin does not consume
// them automatically — a CI release following that path would otherwise fail
// in signReleasePublication before staging anything (dashpay/platform#4045).
// Absent (local dev), this is skipped and, with required=false below, no key
// is needed. The legacy signing.keyId/password/secretKeyRingFile path is
// untouched and still handled by Gradle's standard signing properties.
val signingKey = project.findProperty("signingKey") as String?
val signingPassword = project.findProperty("signingPassword") as String?
if (!signingKey.isNullOrBlank()) {
useInMemoryPgpKeys(signingKey, signingPassword)
}
setRequired(Callable {
gradle.taskGraph.allTasks.any { it is PublishToMavenRepository }
})
sign(publishing.publications["release"])
}
}

// Never publish a coordinate whose AAR lacks the native JNI library
// (libdash_sdk_jni.so). ../build_android.sh (cargo-ndk) produces it into
// src/main/jniLibs/<abi>/; that directory is gitignored and absent in a clean
// checkout, so without this guard a publish from a fresh tree yields an artifact
// that installs cleanly but dies at runtime with UnsatisfiedLinkError
// (NativeLoader.ensureLoaded → System.loadLibrary("dash_sdk_jni")).
val requiredJniAbis = listOf("arm64-v8a", "x86_64") // build_android.sh ABI policy

fun missingJniAbis(): List<String> = requiredJniAbis.filterNot { abi ->
file("src/main/jniLibs/$abi/libdash_sdk_jni.so").isFile
}

fun jniMissingMessage(missing: List<String>): String =
"Native JNI library libdash_sdk_jni.so is missing for ABI(s) " +
"${missing.joinToString()} under src/main/jniLibs/ — run ./build_android.sh " +
"before publishing so the coordinate isn't broken at runtime."

// Remote / staging publish: ALWAYS hard-fail on a missing .so. `-PallowMissingJni`
// is deliberately NOT honored here — a nativeless AAR must never reach a network
// repo, nor jreleaser's build/staging-deploy dir (which `jreleaserDeploy` then
// uploads to Maven Central). This closes the escape where `-PallowMissingJni`
// previously downgraded the check to a warning for every publish task
// (dashpay/platform#4045, finding 8b899bc2e5e8).
val verifyJniLibsForRemotePublish = tasks.register("verifyJniLibsForRemotePublish") {
doLast {
val missing = missingJniAbis()
if (missing.isNotEmpty()) {
throw GradleException(
jniMissingMessage(missing) +
" -PallowMissingJni is a local-only escape and is NOT honored for " +
"remote/staging deploys."
)
}
}
}

// Local publish (`publishToMavenLocal`): a dev may pass `-PallowMissingJni` for a
// POM/metadata-only dry run into the local ~/.m2, which never leaves the machine.
val verifyJniLibsForLocalPublish = tasks.register("verifyJniLibsForLocalPublish") {
doLast {
val missing = missingJniAbis()
if (missing.isNotEmpty()) {
val message = jniMissingMessage(missing)
if (project.hasProperty("allowMissingJni")) {
logger.warn(
"WARNING: $message (continuing: -PallowMissingJni is set — local publish only.)"
)
} else {
throw GradleException("$message Pass -PallowMissingJni for a local-only dry run.")
}
}
}
}

tasks.withType<PublishToMavenRepository>().configureEach {
dependsOn(verifyJniLibsForRemotePublish)
}
tasks.withType<PublishToMavenLocal>().configureEach {
dependsOn(verifyJniLibsForLocalPublish)
}
// jreleaser's deploy/upload/release tasks push the already-staged artifacts, so
// gate them on the strict remote check too — a staging dir populated out-of-band
// (or by a future task rewiring) can't be uploaded without the native library.
tasks.matching { task ->
task.name.startsWith("jreleaser") &&
listOf("Deploy", "Upload", "Release").any { task.name.contains(it) }
}.configureEach { dependsOn(verifyJniLibsForRemotePublish) }

// Maven Central / Sonatype deployment via jreleaser, mirroring
// dashj/core/build.gradle (https://github.com/dashpay/dashj/blob/master/core/build.gradle#L139).
// Flow: `maven-publish` stages the signed artifacts into build/staging-deploy (the
// `staging` repository configured above), then `./gradlew :sdk:jreleaserDeploy`
// uploads them — release versions to Maven Central, -SNAPSHOT versions to the
// Sonatype snapshots repo. jreleaser reads its GPG key and Sonatype credentials
// from env/props (JRELEASER_GPG_*, JRELEASER_MAVENCENTRAL_*/JRELEASER_NEXUS2_*)
// at deploy time only, so `publishToMavenLocal` and any build that never invokes a
// jreleaser task need no signing key or account.
jreleaser {
project {
name.set("dash-sdk-android")
description.set(
"Kotlin SDK for Dash Core (L1 SPV) and Dash Platform " +
"(identities, DPNS, DashPay, shielded balances)"
)
links {
homepage.set("https://github.com/dashpay/platform")
}
authors.set(listOf("Dash Platform Contributors"))
license.set("MIT")
gitRootSearch.set(true)
}

signing {
active.set(Active.ALWAYS)
armored.set(true)
}

deploy {
maven {
// Tagged release versions -> Maven Central via the Sonatype portal.
mavenCentral {
create("sonatype") {
active.set(Active.RELEASE)
url.set("https://central.sonatype.com/api/v1/publisher")
stagingRepository(
layout.buildDirectory.dir("staging-deploy").get().asFile.path
)
}
}
// -SNAPSHOT versions -> Sonatype snapshots repository.
nexus2 {
create("snapshots") {
active.set(Active.SNAPSHOT)
url.set("https://central.sonatype.com/repository/maven-snapshots/")
snapshotUrl.set("https://central.sonatype.com/repository/maven-snapshots/")
applyMavenCentralRules.set(true)
snapshotSupported.set(true)
closeRepository.set(true)
releaseRepository.set(true)
stagingRepository(
layout.buildDirectory.dir("staging-deploy").get().asFile.path
)
}
}
}
}
}
Loading
Loading