From a239f27cd953c3f5fc0eb260267c3b8ec5aa2f3d Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 14:36:44 +0200 Subject: [PATCH 1/3] Prepare 5.2.1 release --- CHANGELOG.md | 11 +++- RELEASE.md | 49 ++++++++++----- build.gradle | 14 +++-- release.gradle | 42 +++++++++---- .../nl/martijndwars/webpush/Notification.java | 12 ++-- .../webpush/AbstractPushServiceTest.java | 59 +++++++++++++++++++ .../webpush/NotificationTest.java | 18 ++++++ 7 files changed, 165 insertions(+), 40 deletions(-) create mode 100644 src/test/java/nl/martijndwars/webpush/AbstractPushServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d77ef..277425a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 5.2.1 (unreleased) + +* Fix Maven publication dependency scopes so dependencies required by the public API are available on consumers' compile classpaths (#219, #233). +* Preserve non-default endpoint ports when building the VAPID JWT audience origin (#153). +* Migrate the Gradle Maven Central publishing flow from the retired OSSRH service to the Central Portal OSSRH Staging API compatibility service. +* Include the fixes that were prepared after 5.1.2 for the tagged but unpublished 5.2.0 release: + * Encode the VAPID public application server key as unpadded URL-safe Base64 (#216). + * Use AES128GCM as the default content encoding. + * Upgrade `org.bitbucket.b_c:jose4j` to 0.9.6. + # 5.1.1 * Target Java 8 instead of Java 7. @@ -24,4 +34,3 @@ * Support [aes128gcm content encoding](https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09#section-2) (#72) * Use `PushService.send(Notification, Encoding)` or the analogous `sendAsync` with `Encoding.AES128GCM`. * Remove Guava dependency (#69) - diff --git a/RELEASE.md b/RELEASE.md index dfa4724..c92f59f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,44 +1,61 @@ # Release process -0. Update CHANGELOG.md. Include changes to the source code, changes to the version of compile dependencies, etc. Do NOT include changes to the buildscript, version of test dependencies, etc. +The project is published with Gradle. Do not maintain a `pom.xml` by hand; Maven metadata is generated by Gradle's `maven-publish` plugin. -1. Update version string in `build.gradle` (1x), `README.md` (2x) to the new (non-SNAPSHOT) version. +Maven Central publishing uses the Central Portal OSSRH Staging API compatibility service. Use a Central Portal user token via the Gradle properties `sonatypeUsername` and `sonatypePassword`. The legacy `ossrhUsername` and `ossrhPassword` property names are still accepted as a fallback. +0. Update `CHANGELOG.md`. Include changes to the source code, public dependency versions, and release behavior. Do not include routine test-only dependency updates. + +1. Run the tests and verify the generated Maven dependency scopes: + +```sh +./gradlew clean check +./gradlew -Prelease verifyPublicationPom ``` + +2. Update the version string in `build.gradle` (1x) and `README.md` (2x) to the new non-SNAPSHOT version: + +```sh ./scripts/version.sh OLD_VERSION NEW_VERSION ``` -2. Commit "Release x.y.z", tag this commit with the new version "x.y.z". +3. Commit `Release x.y.z` and tag that commit with the new version: -``` -git add README.md build.gradle +```sh +git add CHANGELOG.md README.md build.gradle release.gradle RELEASE.md git commit -m "Release NEW_VERSION" git tag -a NEW_VERSION -m "Version NEW_VERSION" -git push --tags ``` -3. [Deploy to OSSRH with Gradle](http://central.sonatype.org/pages/gradle.html): +4. Publish the signed artifacts and close the staging repository: -``` -./gradlew -Prelease clean publish +```sh +./gradlew -Prelease clean publishToSonatype closeSonatypeStagingRepository ``` -4. [Releasing the Deployment](http://central.sonatype.org/pages/releasing-the-deployment.html): +5. Inspect the closed deployment in the Maven Central Publisher Portal. When it is ready to release: -``` -./gradlew -Prelease closeAndReleaseRepository +```sh +./gradlew -Prelease findSonatypeStagingRepository releaseSonatypeStagingRepository ``` -5. Increment to next version and add a -SNAPSHOT suffix +6. Push the release commit and tag: +```sh +git push +git push --tags ``` + +7. Increment to the next development version and add a `-SNAPSHOT` suffix: + +```sh ./scripts/version.sh OLD_VERSION NEW_VERSION-SNAPSHOT ``` -6. Create a commit for the new version "Set version to a.b.c-SNAPSHOT" +8. Commit the new development version: -``` +```sh git add README.md build.gradle git commit -m "Set version to NEW_VERSION-SNAPSHOT" +git push ``` - diff --git a/build.gradle b/build.gradle index 83ca068..dff0e22 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,12 @@ plugins { id 'application' + id 'java-library' id 'com.github.johnrengelman.shadow' version '7.1.1' // Used by release.gradle id 'maven-publish' id 'signing' - id 'io.codearte.nexus-staging' version '0.30.0' + id 'io.github.gradle-nexus.publish-plugin' version '2.0.0' apply false } apply plugin: 'application' @@ -21,19 +22,19 @@ repositories { dependencies { // For CLI - implementation group: 'com.beust', name: 'jcommander', version: '1.81' + api group: 'com.beust', name: 'jcommander', version: '1.81' // For making HTTP requests - implementation group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.5' + api group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.5' // For making async HTTP requests - implementation group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.4' + api group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.4' // For cryptographic operations shadow group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.70' // For creating and signing JWT - implementation group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.9.6' + api group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.9.6' // For parsing JSON testImplementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9' @@ -58,7 +59,7 @@ dependencies { } wrapper { - gradleVersion = '5.1' + gradleVersion = '7.2' } compileJava { @@ -104,5 +105,6 @@ artifacts { } if (hasProperty('release')) { + apply plugin: 'io.github.gradle-nexus.publish-plugin' apply from: 'release.gradle' } diff --git a/release.gradle b/release.gradle index 4c09f1d..7491f62 100644 --- a/release.gradle +++ b/release.gradle @@ -27,16 +27,17 @@ publishing { } } } - } } +} + +nexusPublishing { repositories { - maven { - credentials { - username ossrhUsername - password ossrhPassword - } - url getRepositoryUrl() + sonatype { + nexusUrl.set(uri('https://ossrh-staging-api.central.sonatype.com/service/local/')) + snapshotRepositoryUrl.set(uri('https://central.sonatype.com/repository/maven-snapshots/')) + username = project.findProperty('sonatypeUsername') ?: project.findProperty('ossrhUsername') + password = project.findProperty('sonatypePassword') ?: project.findProperty('ossrhPassword') } } } @@ -45,10 +46,27 @@ signing { sign publishing.publications.mavenJava } -def getRepositoryUrl() { - if (version.endsWith('SNAPSHOT')) { - return 'https://oss.sonatype.org/content/repositories/snapshots/' - } else { - return 'https://oss.sonatype.org/service/local/staging/deploy/maven2/' +tasks.register('verifyPublicationPom') { + dependsOn 'generatePomFileForMavenJavaPublication' + + doLast { + def pomFile = file("$buildDir/publications/mavenJava/pom-default.xml") + def pom = new XmlSlurper().parse(pomFile) + def scopes = [:] + + pom.dependencies.dependency.each { dependency -> + scopes["${dependency.groupId.text()}:${dependency.artifactId.text()}"] = dependency.scope.text() + } + + [ + 'com.beust:jcommander', + 'org.apache.httpcomponents:httpasyncclient', + 'org.asynchttpclient:async-http-client', + 'org.bitbucket.b_c:jose4j' + ].each { dependency -> + if (scopes[dependency] != 'compile') { + throw new GradleException("Expected ${dependency} to be published with compile scope, got '${scopes[dependency]}'") + } + } } } diff --git a/src/main/java/nl/martijndwars/webpush/Notification.java b/src/main/java/nl/martijndwars/webpush/Notification.java index 6fdc493..c3c6329 100644 --- a/src/main/java/nl/martijndwars/webpush/Notification.java +++ b/src/main/java/nl/martijndwars/webpush/Notification.java @@ -87,10 +87,10 @@ public Notification(String endpoint, String userPublicKey, String userAuth, Stri this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload.getBytes(UTF_8)); } - public Notification(String endpoint, String userPublicKey, String userAuth, String payload, Urgency urgency) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { - this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload.getBytes(UTF_8)); - this.urgency = urgency; - } + public Notification(String endpoint, String userPublicKey, String userAuth, String payload, Urgency urgency) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { + this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload.getBytes(UTF_8)); + this.urgency = urgency; + } public Notification(Subscription subscription, String payload) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, payload); @@ -156,8 +156,10 @@ public String getTopic() { public String getOrigin() throws MalformedURLException { URL url = new URL(getEndpoint()); + int port = url.getPort(); + boolean includePort = port != -1 && port != url.getDefaultPort(); - return url.getProtocol() + "://" + url.getHost(); + return url.getProtocol() + "://" + url.getHost() + (includePort ? ":" + port : ""); } public static NotificationBuilder builder() { diff --git a/src/test/java/nl/martijndwars/webpush/AbstractPushServiceTest.java b/src/test/java/nl/martijndwars/webpush/AbstractPushServiceTest.java new file mode 100644 index 0000000..79aa6f2 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/AbstractPushServiceTest.java @@ -0,0 +1,59 @@ +package nl.martijndwars.webpush; + +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractPushServiceTest { + + private static final String USER_PUBLIC_KEY = "BGu3hOwCLOBfdMReXf7-SD2x5tKs_vPapOneyngBOnu6PgNYdgLPKFAodfBnG60MqkXC0McPFehN2Kyuh6TKm14="; + + @BeforeAll + public static void addSecurityProvider() { + Security.addProvider(new BouncyCastleProvider()); + } + + @Test + public void vapidCryptoKeyUsesUnpaddedBase64Url() throws Exception { + ECNamedCurveParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec("prime256v1"); + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDH", "BC"); + keyPairGenerator.initialize(parameterSpec); + + TestPushService service = new TestPushService(keyPairGenerator.generateKeyPair()); + String userAuth = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[16]); + Notification notification = new Notification( + "https://push.example.test/send/123", + USER_PUBLIC_KEY, + userAuth, + "payload" + ); + + HttpRequest request = service.prepare(notification, Encoding.AES128GCM); + String cryptoKey = request.getHeaders().get("Crypto-Key"); + + assertNotNull(cryptoKey); + assertTrue(cryptoKey.startsWith("p256ecdsa=")); + assertFalse(cryptoKey.substring("p256ecdsa=".length()).contains("=")); + } + + private static class TestPushService extends AbstractPushService { + TestPushService(KeyPair keyPair) { + super(keyPair); + } + + HttpRequest prepare(Notification notification, Encoding encoding) throws Exception { + return prepareRequest(notification, encoding); + } + } +} diff --git a/src/test/java/nl/martijndwars/webpush/NotificationTest.java b/src/test/java/nl/martijndwars/webpush/NotificationTest.java index a9d1c6c..3f20aeb 100644 --- a/src/test/java/nl/martijndwars/webpush/NotificationTest.java +++ b/src/test/java/nl/martijndwars/webpush/NotificationTest.java @@ -40,4 +40,22 @@ public void testDefaultTtl() throws GeneralSecurityException { .build(); assertEquals(28 * oneDayDurationInSeconds, notification.getTTL()); } + + @Test + public void testOriginOmitsDefaultPort() throws Exception { + Notification notification = Notification.builder() + .endpoint("https://push.example.test:443/send/123") + .build(); + + assertEquals("https://push.example.test", notification.getOrigin()); + } + + @Test + public void testOriginIncludesNonDefaultPort() throws Exception { + Notification notification = Notification.builder() + .endpoint("https://push.example.test:8443/send/123") + .build(); + + assertEquals("https://push.example.test:8443", notification.getOrigin()); + } } From 79ed9ad6c74ae1ba79a55af33ab2543508a7c017 Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 14:38:35 +0200 Subject: [PATCH 2/3] Make release version script portable --- scripts/version.sh | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/scripts/version.sh b/scripts/version.sh index 7f22121..c3bc7ea 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -2,19 +2,16 @@ set -eu if [ "$#" -ne 2 ]; then - echo "Usage: version.sh OLD_VERSION NEW_VERSION" && exit 1 + echo "Usage: version.sh OLD_VERSION NEW_VERSION" >&2 + exit 1 fi OLD_VERSION=$1 NEW_VERSION=$2 +OLD_VERSION_REGEX=$(printf '%s' "$OLD_VERSION" | sed 's/\./\\./g') -files=( - "build.gradle" - "README.md" -) - -for file in ${files[@]}; do - sed -i '' "s/$OLD_VERSION/$NEW_VERSION/g" $file +for file in build.gradle README.md; do + tmp_file="${file}.tmp" + sed "s/${OLD_VERSION_REGEX}/${NEW_VERSION}/g" "$file" > "$tmp_file" + mv "$tmp_file" "$file" done - - From 091321d16d3d25fd7a7ff99e3fa452050f9e340a Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 22:37:56 +0200 Subject: [PATCH 3/3] Keep historical release commands --- RELEASE.md | 49 ++++++++++++++++--------------------------------- release.gradle | 13 +++++++++++++ 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index c92f59f..dfa4724 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,61 +1,44 @@ # Release process -The project is published with Gradle. Do not maintain a `pom.xml` by hand; Maven metadata is generated by Gradle's `maven-publish` plugin. +0. Update CHANGELOG.md. Include changes to the source code, changes to the version of compile dependencies, etc. Do NOT include changes to the buildscript, version of test dependencies, etc. -Maven Central publishing uses the Central Portal OSSRH Staging API compatibility service. Use a Central Portal user token via the Gradle properties `sonatypeUsername` and `sonatypePassword`. The legacy `ossrhUsername` and `ossrhPassword` property names are still accepted as a fallback. +1. Update version string in `build.gradle` (1x), `README.md` (2x) to the new (non-SNAPSHOT) version. -0. Update `CHANGELOG.md`. Include changes to the source code, public dependency versions, and release behavior. Do not include routine test-only dependency updates. - -1. Run the tests and verify the generated Maven dependency scopes: - -```sh -./gradlew clean check -./gradlew -Prelease verifyPublicationPom ``` - -2. Update the version string in `build.gradle` (1x) and `README.md` (2x) to the new non-SNAPSHOT version: - -```sh ./scripts/version.sh OLD_VERSION NEW_VERSION ``` -3. Commit `Release x.y.z` and tag that commit with the new version: +2. Commit "Release x.y.z", tag this commit with the new version "x.y.z". -```sh -git add CHANGELOG.md README.md build.gradle release.gradle RELEASE.md +``` +git add README.md build.gradle git commit -m "Release NEW_VERSION" git tag -a NEW_VERSION -m "Version NEW_VERSION" +git push --tags ``` -4. Publish the signed artifacts and close the staging repository: +3. [Deploy to OSSRH with Gradle](http://central.sonatype.org/pages/gradle.html): -```sh -./gradlew -Prelease clean publishToSonatype closeSonatypeStagingRepository ``` - -5. Inspect the closed deployment in the Maven Central Publisher Portal. When it is ready to release: - -```sh -./gradlew -Prelease findSonatypeStagingRepository releaseSonatypeStagingRepository +./gradlew -Prelease clean publish ``` -6. Push the release commit and tag: +4. [Releasing the Deployment](http://central.sonatype.org/pages/releasing-the-deployment.html): -```sh -git push -git push --tags +``` +./gradlew -Prelease closeAndReleaseRepository ``` -7. Increment to the next development version and add a `-SNAPSHOT` suffix: +5. Increment to next version and add a -SNAPSHOT suffix -```sh +``` ./scripts/version.sh OLD_VERSION NEW_VERSION-SNAPSHOT ``` -8. Commit the new development version: +6. Create a commit for the new version "Set version to a.b.c-SNAPSHOT" -```sh +``` git add README.md build.gradle git commit -m "Set version to NEW_VERSION-SNAPSHOT" -git push ``` + diff --git a/release.gradle b/release.gradle index 7491f62..6fd7dc6 100644 --- a/release.gradle +++ b/release.gradle @@ -70,3 +70,16 @@ tasks.register('verifyPublicationPom') { } } } + +// Keep the historical release commands stable while using the current Sonatype plugin internally. +gradle.projectsEvaluated { + tasks.named('publish') { + dependsOn 'publishToSonatype' + } +} + +tasks.register('closeAndReleaseRepository') { + group = 'publishing' + description = 'Closes and releases the current Sonatype staging repository.' + dependsOn 'findSonatypeStagingRepository', 'closeAndReleaseSonatypeStagingRepository' +}