diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..117aa58 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,70 @@ +name: Publish to Maven Central + +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.1.0, without the 'v')" + required: true + type: string + release: + description: "Publish and release. Off = upload to the Portal and stop at VALIDATED for manual review." + required: true + type: boolean + default: false + +jobs: + guard: + name: Require green main.yml for this commit + runs-on: ubuntu-latest + permissions: + actions: read + steps: + - name: Check latest main.yml conclusion + env: + GH_TOKEN: ${{ github.token }} + run: | + conclusion=$(gh api \ + "repos/${{ github.repository }}/actions/workflows/main.yml/runs?head_sha=${{ github.sha }}&status=completed" \ + --jq '.workflow_runs[0].conclusion // "missing"') + echo "main.yml conclusion for ${{ github.sha }}: $conclusion" + if [ "$conclusion" != "success" ]; then + echo "::error::main.yml is not green for ${{ github.sha }} (got: $conclusion). Run publish from a commit on main whose CI passed." + exit 1 + fi + + publish: + name: Publish to Maven Central + needs: guard + runs-on: ubuntu-latest + environment: + name: maven-central + url: https://central.sonatype.com/artifact/app.marketdata/marketdata-sdk-java + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build and test + run: ./gradlew clean build -PsdkVersion=${{ inputs.version }} + + - name: Upload to Portal (validate only) + if: ${{ !inputs.release }} + run: ./gradlew publishToMavenCentral -PsdkVersion=${{ inputs.version }} + + - name: Publish and release + if: ${{ inputs.release }} + run: ./gradlew publishAndReleaseToMavenCentral -PsdkVersion=${{ inputs.version }} diff --git a/README.md b/README.md index 8e20afe..48be050 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ exception taxonomy, and Kotlin-interop foundations are in place. ```kotlin // build.gradle.kts dependencies { - implementation("com.marketdata:marketdata-sdk-java:0.1.0") + implementation("app.marketdata:marketdata-sdk-java:0.1.0") } ``` diff --git a/build.gradle.kts b/build.gradle.kts index fb7f57a..a5573d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import com.vanniktech.maven.publish.SonatypeHost + plugins { `java-library` jacoco @@ -5,8 +7,14 @@ plugins { alias(libs.plugins.vanniktech.publish) } -group = "com.marketdata" -version = "0.1.0-SNAPSHOT" +// Maven groupId = the verified Central Portal namespace (domain marketdata.app, +// reversed). Independent of the Java package, which stays com.marketdata.sdk. +group = "app.marketdata" + +// Version is overridable from the command line so a manual Central Portal +// validation run can use a real release version (e.g. `-PsdkVersion=0.1.0`) +// without committing it. Default stays on the in-development SNAPSHOT. +version = (findProperty("sdkVersion") as String?) ?: "0.1.0-SNAPSHOT" // ADR-002: minimum JDK 17, build with --release 17, single bytecode level. java { @@ -174,13 +182,46 @@ spotless { } // ADR-003 / requirements §15: Maven Central publishing via Vanniktech. -// Coordinates and POM metadata below are placeholders — fill in before -// the first publication. +// +// Publishes to the Sonatype Central Portal (central.sonatype.com). +// `automaticRelease = false` uploads the deployment but leaves it in the +// VALIDATED state for manual review/release (or drop) from the portal UI — +// the safe path for a first manual validation run. +// +// Upload + signing credentials are read from Gradle properties / env vars by +// the plugin (never hard-coded here): +// - ORG_GRADLE_PROJECT_mavenCentralUsername / _mavenCentralPassword +// - ORG_GRADLE_PROJECT_signingInMemoryKey / _signingInMemoryKeyPassword +// (optionally _signingInMemoryKeyId) mavenPublishing { + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = false) + signAllPublications() + coordinates(group.toString(), "marketdata-sdk-java", version.toString()) pom { name.set("Market Data Java SDK") description.set("Java SDK for the Market Data API.") - // TODO: set url, scm, license, developers before publishing. + url.set("https://github.com/MarketDataApp/sdk-java") + inceptionYear.set("2026") + + licenses { + license { + name.set("MIT License") + url.set("https://github.com/MarketDataApp/sdk-java/blob/main/LICENSE") + distribution.set("repo") + } + } + developers { + developer { + id.set("marketdata") + name.set("Market Data") + url.set("https://www.marketdata.app") + } + } + scm { + url.set("https://github.com/MarketDataApp/sdk-java") + connection.set("scm:git:git://github.com/MarketDataApp/sdk-java.git") + developerConnection.set("scm:git:ssh://git@github.com/MarketDataApp/sdk-java.git") + } } } diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts index 1fa4d97..1bd71b9 100644 --- a/examples/consumer-test/build.gradle.kts +++ b/examples/consumer-test/build.gradle.kts @@ -14,7 +14,7 @@ kotlin { } dependencies { - implementation("com.marketdata:marketdata-sdk-java:0.1.0-SNAPSHOT") + implementation("app.marketdata:marketdata-sdk-java:0.1.0-SNAPSHOT") } // Default `./gradlew run` lands on the stocks resource example (live API). diff --git a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java index 1c9c3b6..d9fb713 100644 --- a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java +++ b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java @@ -222,14 +222,16 @@ void closeCompletesAllQueuedWaitersWithCancellation() { sem.close(); - // CompletableFuture#join unwraps CancellationException specifically: it surfaces directly - // rather than being wrapped in CompletionException. That's the same propagation downstream - // observers see, so we assert the bare exception shape here. + // join() surfaces a CancellationException, but its shape is JDK-dependent: JDK 17 rethrows the + // original directly (message "AsyncSemaphore is closed"), while JDK 21+ wraps it in a fresh + // CancellationException (message "join") carrying the original as its cause. Accept either. for (CompletableFuture w : List.of(w1, w2, w3)) { assertThat(w).isCompletedExceptionally(); assertThatThrownBy(w::join) .isInstanceOf(CancellationException.class) - .hasMessageContaining("closed"); + .satisfiesAnyOf( + t -> assertThat(t).hasMessageContaining("closed"), + t -> assertThat(t).hasRootCauseMessage("AsyncSemaphore is closed")); } assertThat(sem.queueLength()).isZero(); } @@ -244,7 +246,9 @@ void acquireAfterCloseReturnsFailedFutureImmediately() { assertThat(failed).isCompletedExceptionally(); assertThatThrownBy(failed::join) .isInstanceOf(CancellationException.class) - .hasMessageContaining("closed"); + .satisfiesAnyOf( + t -> assertThat(t).hasMessageContaining("closed"), + t -> assertThat(t).hasRootCauseMessage("AsyncSemaphore is closed")); assertThat(sem.queueLength()).isZero(); }