diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cd3b5a91..ad62030a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,9 @@ jobs: run: chmod +x gradlew - name: Build and Test with Coverage - run: ./gradlew clean build koverXmlReport --stacktrace --continue + run: | + ./gradlew -p tooling :plugins:test :plugins:ktlintCheck --stacktrace + ./gradlew clean build koverXmlReport --stacktrace --continue - name: Upload Coverage to Codecov # Secrets (including CODECOV_TOKEN) are not exposed to fork PRs, so the @@ -83,6 +85,9 @@ jobs: run: | echo "VERSION_NAME=$(grep -E '^store[[:space:]]*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)" >> $GITHUB_ENV + - name: Verify publication versions + run: ./gradlew verifyPublicationVersions --stacktrace + - name: Publish to Maven Central (Central Portal) env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} @@ -94,4 +99,4 @@ jobs: ./gradlew publishToMavenCentral else ./gradlew publishAndReleaseToMavenCentral - fi \ No newline at end of file + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf3da5d7..b9558a569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ### Thank you to all our wonderful contributors and users +## [5.1.0-beta01] (2026-09-08) + +**Bug Fixes** + +* Fix MutableStore synchronization so a successful server update acknowledges only its captured batch, preserving newer pending writes and excluding failed local writes [#762](https://github.com/MobileNativeFoundation/Store/pull/762) +* Propagate cancellation, retain admitted pending writes in memory until acknowledgement, and unblock SourceOfTruth readers after a cancelled write [#762](https://github.com/MobileNativeFoundation/Store/pull/762) +* Run success callbacks after releasing synchronization locks so they can reenter the store, and continue delivering callbacks if an earlier callback fails [#762](https://github.com/MobileNativeFoundation/Store/pull/762) +* Detect recursive adapter calls into the same store and key through inherited coroutine context and fail before reentry can deadlock [#762](https://github.com/MobileNativeFoundation/Store/pull/762) + +**Improvements** + +* Serialize MutableStore updater calls per key while allowing newer writes to persist locally during an earlier update [#762](https://github.com/MobileNativeFoundation/Store/pull/762) +* Align the Rx2 artifact's publication version with the shared Store release version + ## [5.1.0-alpha11] (2026-08-28) **Bug Fixes** @@ -395,7 +409,9 @@ This is a first alpha release of Store ported to RxJava 2. * The change log for Store version 1.x can be found [here](https://github.com/NYTimes/Store/blob/develop/CHANGELOG.md). -[Unreleased]: https://github.com/MobileNativeFoundation/Store/compare/5.1.0-alpha11...HEAD +[Unreleased]: https://github.com/MobileNativeFoundation/Store/compare/5.1.0-beta01...HEAD + +[5.1.0-beta01]: https://github.com/MobileNativeFoundation/Store/releases/tag/5.1.0-beta01 [5.1.0-alpha11]: https://github.com/MobileNativeFoundation/Store/releases/tag/5.1.0-alpha11 diff --git a/RELEASING.md b/RELEASING.md index 4d42171d9..0d65ae05a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,28 +1,35 @@ -Releasing -======== +# Releasing -1. Change the version in top level `gradle.properties` to a non-SNAPSHOT version. -2. Update the `cocoapods` version in `build.gradle.kts` in `:store`. -3. Modify `create_swift_package.yml` workflow. - * https://github.com/MobileNativeFoundation/Store/blob/e526400cdf51aa2f78b6b7e9e87f4a6845e6dcea/.github/workflows/create_swift_package.yml -4. Update the `CHANGELOG.md` for the impending release. -5. Update the `README.md` with the new version. -6. `git commit -sam "Prepare for release X.Y.Z."` (where X.Y.Z is the new version) -7. `git tag -a X.Y.X -m "Version X.Y.Z"` (where X.Y.Z is the new version) - * Run `git tag` to verify it. -8. `git push && git push --tags` - * This should be pushed to your fork. -9. Create a PR with this commit and merge it. -10. Update the top level `build.gradle` to the next SNAPSHOT version. -11. Modify `create_swift_package.yml` workflow to only run manually. - * https://github.com/MobileNativeFoundation/Store/blob/de9ed1764408eeaafe5e58fe602205c875a8b0b0/.github/workflows/create_swift_package.yml -12. `git commit -am "Prepare next development version."` -13. Create a PR with this commit and merge it. -14. Login to Sonatype to promote the artifacts https://central.sonatype.org/pages/releasing-the-deployment.html - * This part is automated. If it fails in CI, follow the steps below. - * Click on Staging Repositories under Build Promotion - * Select all the Repositories that contain the content you want to release - * Click on Close and refresh until the Release button is active - * Click Release and submit -15. Update the sample module's `build.gradle` to point to the newly released version. (It may take ~2 hours for artifact to be available after release) - \ No newline at end of file +1. Set `store` in `gradle/libs.versions.toml` to the new version. The Android and + Kotlin Multiplatform conventions use it for Maven publications, and the + Multiplatform convention also uses it for CocoaPods. +2. Update `CHANGELOG.md`, including the release link and `Unreleased` comparison. +3. Validate the build tooling and generated publication metadata before opening + the release PR: + + ```sh + ./gradlew -p tooling :plugins:test :plugins:ktlintCheck + ./gradlew verifyPublicationVersions + ``` + + The metadata check validates generated POMs and Gradle module metadata, + including internal dependency versions and target redirects. Host-disabled + metadata generators can skip execution. Validate on macOS as well as Linux + to cover Apple publications. Existing files from skipped tasks are not checked. +4. Commit with a DCO sign-off and open a PR. Require passing tests, API and + formatting checks, and CI on the release commit. Record JVM and Native test + execution separately. +5. Merge the approved release PR. A successful push build on `main` automatically + publishes to Maven Central. Non-SNAPSHOT versions use + `publishAndReleaseToMavenCentral`; SNAPSHOT versions use `publishToMavenCentral`. + The macOS publication job verifies metadata before uploading artifacts. +6. Verify that consumers can resolve the new Maven coordinates and their internal + dependencies after publication completes. +7. For Swift distribution, run the **KMMBridge-Publish** workflow for the tested + release commit. Its reusable workflow reads the same catalog version, creates + or finds the GitHub release, publishes XCFrameworks, and updates the release tag. + Mark alpha and beta GitHub releases as prereleases and verify the Swift package + resolves the intended artifacts. + +Do not reuse an already-published version. Prepare any subsequent development +version through another PR, accounting for automatic publication on `main`. diff --git a/gradle.properties b/gradle.properties index df28b34f9..7a7bc1958 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ org.gradle.jvmargs=-XX:MaxMetaspaceSize=2G # POM file GROUP=org.mobilenativefoundation.store # Project version lives in gradle/libs.versions.toml (store) and is applied to -# project.version by KotlinMultiplatformConventionPlugin. +# project.version by the Kotlin Multiplatform and Android convention plugins. POM_PACKAGING=pom POM_DESCRIPTION = Store5 is a Kotlin Multiplatform network-resilient repository layer @@ -26,4 +26,4 @@ POM_DEVELOPER_NAME=Dropbox kotlinx.atomicfu.enableJvmIrTransformation=false kotlinx.atomicfu.enableJsIrTransformation=false -org.jetbrains.compose.experimental.uikit.enabled=true \ No newline at end of file +org.jetbrains.compose.experimental.uikit.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3895248a..ee1f10b6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ kmmBridge = "1.2.1" ktlint = "1.8.0" kover = "0.9.9" #noinspection UnusedVersionCatalogEntry -store = "5.1.0-alpha12" +store = "5.1.0-beta01" truth = "1.4.5" turbine = "1.2.1" binary-compatibility-validator = "0.18.1" @@ -69,4 +69,4 @@ vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = dokka = { id = "org.jetbrains.dokka" , version.ref = "dokkaGradlePlugin"} atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicFu"} kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "baseKotlin"} -kotlin-cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "baseKotlin"} \ No newline at end of file +kotlin-cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "baseKotlin"} diff --git a/tooling/plugins/build.gradle.kts b/tooling/plugins/build.gradle.kts index d0013b351..2cfdc187e 100644 --- a/tooling/plugins/build.gradle.kts +++ b/tooling/plugins/build.gradle.kts @@ -25,6 +25,8 @@ kotlin { } dependencies { + implementation(localGroovy()) + testImplementation(kotlin("test")) compileOnly(libs.android.gradle.plugin) compileOnly(libs.kotlin.gradle.plugin) compileOnly(libs.dokka.gradle.plugin) diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt index 64bc94db5..f5fa90019 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt @@ -12,6 +12,8 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension class AndroidConventionPlugin : Plugin { override fun apply(project: Project) = with(project) { + version = versionCatalog.store + with(pluginManager) { apply("com.android.library") apply("com.vanniktech.maven.publish") diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt index 87c161aeb..594dbcad6 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt @@ -207,11 +207,13 @@ fun KotlinMultiplatformExtension.android(configure: Action Unit) = extensions.configure(action) -fun Project.configureMavenPublishing() = +fun Project.configureMavenPublishing() { + configurePublicationVersionChecks() extensions.configure { publishToMavenCentral(automaticRelease = true) signAllPublications() } +} fun Project.configureKmmBridge() = extensions.configure { diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/PublicationVersionChecks.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/PublicationVersionChecks.kt new file mode 100644 index 000000000..53373e8a2 --- /dev/null +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/PublicationVersionChecks.kt @@ -0,0 +1,112 @@ +package org.mobilenativefoundation.store.tooling.plugins + +import groovy.json.JsonSlurper +import org.gradle.api.Project +import org.gradle.api.publish.maven.tasks.GenerateMavenPom +import org.gradle.api.publish.tasks.GenerateModuleMetadata +import org.gradle.kotlin.dsl.withType +import org.w3c.dom.Element +import java.io.File +import java.net.URI +import javax.xml.parsers.DocumentBuilderFactory + +fun Project.configurePublicationVersionChecks() { + val expectedVersion = versionCatalog.store + val expectedGroup = providers.gradleProperty("GROUP").get() + val pomTasks = tasks.withType() + val metadataTasks = tasks.withType() + + pomTasks.configureEach { + inputs.property("storePublicationVersion", expectedVersion) + inputs.property("storePublicationGroup", expectedGroup) + doLast { + verifyPomVersions((this as GenerateMavenPom).destination, expectedGroup, expectedVersion) + } + } + metadataTasks.configureEach { + inputs.property("storePublicationVersion", expectedVersion) + inputs.property("storePublicationGroup", expectedGroup) + doLast { + verifyModuleVersions((this as GenerateModuleMetadata).outputFile.get().asFile, expectedGroup, expectedVersion) + } + } + + val verification = tasks.register("verifyPublicationVersions") { + group = "verification" + description = "Generates and checks publication versions against the Store version catalog." + dependsOn(pomTasks, metadataTasks) + } + tasks.matching { it.name == "check" }.configureEach { dependsOn(verification) } +} + +internal fun verifyPomVersions(file: File, expectedGroup: String, expectedVersion: String) { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + } + val project = factory.newDocumentBuilder().parse(file).documentElement + require(project.childText("groupId") == expectedGroup) { "${file.path}: unexpected publication group" } + requireVersion(project.childText("version"), expectedVersion, "${file.path}: project") + + for (tag in listOf("dependency", "parent")) { + val references = project.getElementsByTagNameNS("*", tag) + for (index in 0 until references.length) { + val reference = references.item(index) as Element + if (reference.childText("groupId") == expectedGroup) { + requireVersion(reference.childText("version"), expectedVersion, "${file.path}: $tag ${reference.childText("artifactId")}") + } + } + } +} + +internal fun verifyModuleVersions(file: File, expectedGroup: String, expectedVersion: String) { + val metadata = JsonSlurper().parse(file) as Map<*, *> + val component = metadata["component"] as Map<*, *> + require(component["group"] == expectedGroup) { "${file.path}: unexpected component group" } + requireVersion(component["version"], expectedVersion, "${file.path}: component") + component["url"]?.let { verifyRedirect(it as String, component["module"] as String, expectedVersion, file) } + + for (variant in metadata["variants"] as List<*>) { + variant as Map<*, *> + val location = "${file.path}: variant ${variant["name"]}" + for (kind in listOf("dependencies", "dependencyConstraints")) { + for (reference in variant[kind] as? List<*> ?: emptyList()) { + reference as Map<*, *> + if (reference["group"] == expectedGroup) { + val version = reference["version"] as? Map<*, *> ?: emptyMap() + val selectors = listOf("requires", "strictly", "prefers").mapNotNull { version[it] } + require(selectors.isNotEmpty()) { "$location: missing version for ${reference["module"]}" } + selectors.forEach { requireVersion(it, expectedVersion, "$location: ${reference["module"]}") } + require(expectedVersion !in (version["rejects"] as? List<*> ?: emptyList())) { + "$location: ${reference["module"]} rejects $expectedVersion" + } + } + } + } + val redirect = variant["available-at"] as? Map<*, *> + if (redirect != null && redirect["group"] == expectedGroup) { + requireVersion(redirect["version"], expectedVersion, "$location: available-at") + verifyRedirect(redirect["url"] as String, redirect["module"] as String, expectedVersion, file) + } + } +} + +private fun Element.childText(name: String): String? = + (0 until childNodes.length) + .map { childNodes.item(it) } + .filterIsInstance() + .firstOrNull { it.localName == name } + ?.textContent + ?.trim() + +private fun requireVersion(actual: Any?, expected: String, location: String) { + require(actual == expected) { "$location has version '$actual'; expected '$expected' from the Store version catalog" } +} + +private fun verifyRedirect(url: String, module: String, expectedVersion: String, file: File) { + val path = URI(url).normalize().path.orEmpty() + val target = "$module/$expectedVersion/$module-$expectedVersion.module" + require(path == target || path.endsWith("/$target")) { + "${file.path}: metadata redirect '$url' does not reference '$target'" + } +} diff --git a/tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/PublicationVersionChecksTest.kt b/tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/PublicationVersionChecksTest.kt new file mode 100644 index 000000000..68e86ae55 --- /dev/null +++ b/tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/PublicationVersionChecksTest.kt @@ -0,0 +1,128 @@ +package org.mobilenativefoundation.store.tooling.plugins + +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PublicationVersionChecksTest { + private val group = "org.mobilenativefoundation.store" + private val version = "5.1.0-beta01" + + @Test + fun validPomPreservesExternalDependencyVersions() { + withFile(pom(version, version)) { verifyPomVersions(it, group, version) } + } + + @Test + fun rx2UnspecifiedVersionIsRejected() { + withFile(pom("unspecified", version)) { + val error = assertFailsWith { verifyPomVersions(it, group, version) } + assertTrue(error.message.orEmpty().contains("project has version 'unspecified'")) + } + } + + @Test + fun staleInternalPomDependencyIsRejected() { + withFile(pom(version, "5.1.0-alpha12")) { + val error = assertFailsWith { verifyPomVersions(it, group, version) } + assertTrue(error.message.orEmpty().contains("dependency store5")) + } + } + + @Test + fun validModuleIncludesRootComponentRedirect() { + withFile(module()) { verifyModuleVersions(it, group, version) } + } + + @Test + fun unspecifiedModuleComponentIsRejected() { + withFile(module(componentVersion = "unspecified")) { + assertFailsWith { verifyModuleVersions(it, group, version) } + } + } + + @Test + fun staleInternalModuleDependencyIsRejected() { + withFile(module(dependencyVersion = "5.1.0-alpha12")) { + val error = assertFailsWith { verifyModuleVersions(it, group, version) } + assertTrue(error.message.orEmpty().contains("store5")) + } + } + + @Test + fun staleTargetReferenceIsRejected() { + withFile(module(targetVersion = "5.1.0-alpha12")) { + assertFailsWith { verifyModuleVersions(it, group, version) } + } + } + + @Test + fun staleTargetUrlIsRejectedEvenWhenVersionMatches() { + withFile(module(targetUrlVersion = "5.1.0-alpha12")) { + assertFailsWith { verifyModuleVersions(it, group, version) } + } + } + + @Test + fun staleTargetFilenameIsRejectedEvenWhenDirectoryMatches() { + withFile(module(targetFileVersion = "5.1.0-alpha12")) { + assertFailsWith { verifyModuleVersions(it, group, version) } + } + } + + @Test + fun staleRootFilenameIsRejectedEvenWhenDirectoryMatches() { + withFile(module(rootFileVersion = "5.1.0-alpha12")) { + assertFailsWith { verifyModuleVersions(it, group, version) } + } + } + + private fun pom(ownVersion: String, dependencyVersion: String) = + """ + + 4.0.0 + $grouprx2$ownVersion + + $groupstore5$dependencyVersion + io.reactivex.rxjava2rxjava2.2.21 + + + """.trimIndent() + + private fun module( + componentVersion: String = version, + dependencyVersion: String = version, + targetVersion: String = version, + targetUrlVersion: String = version, + targetFileVersion: String = targetUrlVersion, + rootFileVersion: String = version, + ) = + """ + { + "formatVersion": "1.1", + "component": {"group": "$group", "module": "store5", "version": "$componentVersion", "url": "../../store5/$version/store5-$rootFileVersion.module"}, + "variants": [ + {"name": "api", "dependencies": [ + {"group": "$group", "module": "store5", "version": {"requires": "$dependencyVersion"}}, + {"group": "org.jetbrains.kotlin", "module": "kotlin-stdlib", "version": {"requires": "2.3.21"}} + ]}, + {"name": "iosArm64Api", "available-at": { + "group": "$group", "module": "store5-iosarm64", "version": "$targetVersion", + "url": "../../store5-iosarm64/$targetUrlVersion/store5-iosarm64-$targetFileVersion.module" + }} + ] + } + """.trimIndent() + + private fun withFile(content: String, check: (File) -> Unit) { + val directory = createTempDirectory("store-publication-test").toFile() + try { + val file = File(directory, "publication").apply { writeText(content) } + check(file) + } finally { + directory.deleteRecursively() + } + } +}