Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
41 changes: 0 additions & 41 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ subprojects {
setupTestTasks()
setupPublishing()
configureTaskDependencies()
excludeProtoDescriptorsFromBuildCache()
}

KoverConfig.applyTo(project)
Expand Down Expand Up @@ -216,46 +215,6 @@ fun Project.applyPlugins() {
CheckStyleConfig.applyTo(project)
}

/**
* Opts the Protobuf descriptor-generating tasks out of the Gradle build cache.
*
* Spine builds the runtime type registry ([io.spine.type.KnownTypes]) from a Protobuf
* descriptor set that the `generate*Proto` tasks write to a **version-named** file — e.g.
* `build/descriptors/test/io.spine.gcloud_spine-datastore_<version>_test.desc`, referenced
* from `desc.ref`. The Gradle build-cache key for those tasks is derived from the `.proto`
* inputs and the compiler, but not from the project version. With `org.gradle.caching`
* enabled — and the cache persisted across CI runs — a build that follows a version bump
* gets a cache hit and restores a descriptor produced for a different version, so the
* runtime cannot load the expected descriptor and every Protobuf type fails to resolve:
*
* ```
* io.spine.type.UnknownTypeException: No Java class found for the Protobuf message of type: `...`
* ```
*
* Confirmed by reproduction — the affected test passes under `--no-build-cache`. Opting
* these tasks out of the build cache makes them run for the current version; the local
* up-to-date checks still apply, so the cost is negligible. Mirrors the
* `outputs.cacheIf { false }` opt-out already used for TestKit coverage.
*
* Temporary workaround — remove once the upstream fix lands. Tracked in gcloud-jvm issue #200;
* root cause: SpineEventEngine/tool-base#183
* (https://github.com/SpineEventEngine/tool-base/issues/183).
*/
fun Project.excludeProtoDescriptorsFromBuildCache() {
val descriptorTasks = setOf(
"generateProto",
"generateTestProto",
"generateTestFixturesProto",
)
tasks.matching { it.name in descriptorTasks }.configureEach {
outputs.cacheIf(
"Protobuf descriptor sets are version-named, but the build-cache key omits the " +
"project version; a cache hit can restore a descriptor for a stale version, " +
"breaking the `KnownTypes` registry."
) { false }
}
}

/**
* Configures Java tasks in this project.
*/
Expand Down
4 changes: 2 additions & 2 deletions buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ package io.spine.dependency.local
*/
@Suppress("ConstPropertyName", "unused")
object Base {
const val version = "2.0.0-SNAPSHOT.420"
const val versionForBuildScript = "2.0.0-SNAPSHOT.420"
const val version = "2.0.0-SNAPSHOT.421"
const val versionForBuildScript = "2.0.0-SNAPSHOT.421"
const val group = Spine.group
private const val prefix = "spine"
const val libModule = "$prefix-base"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ object CoreJvmCompiler {
/**
* The version used in the build classpath.
*/
const val dogfoodingVersion = "2.0.0-SNAPSHOT.079"
const val dogfoodingVersion = "2.0.0-SNAPSHOT.080"

/**
* The version to be used for integration tests.
*/
const val version = "2.0.0-SNAPSHOT.079"
const val version = "2.0.0-SNAPSHOT.080"

/**
* The ID of the Gradle plugin.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ package io.spine.dependency.local
@Suppress("ConstPropertyName", "unused")
object ToolBase {
const val group = Spine.toolsGroup
const val version = "2.0.0-SNAPSHOT.401"
const val dogfoodingVersion = "2.0.0-SNAPSHOT.401"
const val version = "2.0.0-SNAPSHOT.402"
const val dogfoodingVersion = "2.0.0-SNAPSHOT.402"

const val lib = "$group:tool-base:$version"
const val classicCodegen = "$group:classic-codegen:$version"
Expand Down
109 changes: 89 additions & 20 deletions buildSrc/src/main/kotlin/io/spine/gradle/publish/IncrementGuard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,21 @@

package io.spine.gradle.publish

import io.spine.gradle.Build
import io.spine.gradle.SpineTaskGroup
import io.spine.gradle.base.check
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.publish.maven.tasks.PublishToMavenLocal

/**
* Gradle plugin that adds a [CheckVersionIncrement] task.
* Gradle plugin that adds a [CheckVersionIncrement] task verifying that the
* project version was incremented before its artifacts are published.
*
* The task is called `checkVersionIncrement` inserted before the `check` task.
* The task — named `checkVersionIncrement` — runs before the `check` task and
* before any `publishToMavenLocal` task. It actually executes only when the
* verification is meaningful; see [apply].
*/
class IncrementGuard : Plugin<Project> {

Expand All @@ -57,40 +64,85 @@ class IncrementGuard : Plugin<Project> {
}
return baseBranch.endsWith("master") || baseBranch.endsWith("main")
}

/**
* Tells whether the [CheckVersionIncrement] action must actually run for
* the current build.
*
* The increment is verified in two situations:
* 1. [ciPullRequest] — a CI pull request that must check the version
* (see [shouldCheckVersion]); or
* 2. a local build (not [onCi]) that is going to publish to Maven Local
* ([localPublish]).
*
* CI pushes and tag builds that publish to Maven Local — e.g. to feed
* integration tests — deliberately skip the check, so that re-publishing
* an already released version does not fail them.
*/
internal fun mustVerify(
ciPullRequest: Boolean,
onCi: Boolean,
localPublish: Boolean,
): Boolean = ciPullRequest || (!onCi && localPublish)

/**
* Tells whether [tasks] contains a Maven Local publishing task that
* belongs to the given [project].
*
* The scan is limited to [project]'s own publications so that, in a
* multi-project build, a sibling module's `publishToMavenLocal` does not
* trigger this module's check — which would verify an unrelated version.
*/
internal fun localPublishPlanned(tasks: Iterable<Task>, project: Project): Boolean =
tasks.any { it is PublishToMavenLocal && it.project == project }
}

/**
* Adds the [CheckVersionIncrement] task to the project.
* Adds the [CheckVersionIncrement] task to the [target] project and wires it
* into two execution paths:
*
* 1. The `check` task depends on it, so that CI pull requests verify
* the increment.
* 2. Every `publishToMavenLocal` task depends on it, so that a local publish —
* used by integration tests that consume artifacts from `~/.m2` — cannot
* overwrite an already published version.
*
* The task is created anyway, but it is enabled only if:
* 1. The project is built on GitHub CI, and
* 2. The job is a pull request targeting a default (`master` or `main`) or
* a release-line (e.g. `2.x-jdk8-master`) branch.
* The task is always created and wired, but its action runs only when:
* 1. the build is a GitHub Actions pull request targeting a default
* (`master` or `main`) or a release-line (e.g. `2.x-jdk8-master`) branch; or
* 2. the build runs locally (outside CI) and is going to publish artifacts
* to Maven Local.
*
* It is the responsibility of a branch that aims to merge into a default
* (or otherwise protected) branch to bump the version. Auxiliary branches do not
* deal with the versions in the release cycle, so pull requests targeting them,
* direct pushes, and tag builds do not run the check. This also prevents unexpected
* CI fails when re-building `master` multiple times, creating git tags, and in other
* cases that go outside the "usual" development cycle.
* direct pushes, and tag builds do not run the check. In particular, the
* Maven Local guard is restricted to local builds: re-building `master`,
* creating git tags, and other CI jobs that publish locally (e.g. to feed
* integration tests) keep succeeding even though their version is already
* published. Ordinary local builds that do not publish stay free from the
* network-bound version check as well.
*/
override fun apply(target: Project) {
val tasks = target.tasks
tasks.register(taskName, CheckVersionIncrement::class.java) {
val checkVersion = tasks.register(taskName, CheckVersionIncrement::class.java) {
group = SpineTaskGroup.name
description = "Verifies that the project version was incremented before publishing"
repository = CloudArtifactRegistry.repository
tasks.getByName("check").dependsOn(this)

if (!shouldCheckVersion()) {
logger.info(
"The build does not represent a GitHub Actions pull request job " +
"targeting a default or a release-line branch, " +
"the `checkVersionIncrement` task is disabled."
)
this.enabled = false
onlyIf {
mustVerify(shouldCheckVersion(), Build.ci, it.publishesToMavenLocal())
}
}

// Verify the increment on CI pull requests via the `check` lifecycle task.
tasks.check.configure { dependsOn(checkVersion) }

// Verify it before publishing to Maven Local too: integration tests in this
// and sibling projects consume the freshly published artifacts from `~/.m2`,
// so a non-incremented version would let them pick up a stale artifact.
tasks.withType(PublishToMavenLocal::class.java).configureEach {
dependsOn(checkVersion)
}
}

/**
Expand All @@ -110,3 +162,20 @@ class IncrementGuard : Plugin<Project> {
return shouldCheckVersion(event, baseBranch)
}
}

/**
* Tells whether the current build is going to publish this task's project to
* Maven Local.
*
* Integration tests in this and sibling projects consume freshly built artifacts
* from `~/.m2`. Publishing them under a version that already exists would let those
* tests pick up a stale artifact, so the version increment must be verified before
* any local publication runs.
*
* Only this task's own project is considered: a sibling module's local publish in
* the same invocation must not trigger this module's check. The predicate is
* evaluated lazily as a task `onlyIf` spec, by which point the execution
* [task graph][org.gradle.api.execution.TaskExecutionGraph] is fully populated.
*/
private fun Task.publishesToMavenLocal(): Boolean =
IncrementGuard.localPublishPlanned(project.gradle.taskGraph.allTasks, project)
106 changes: 106 additions & 0 deletions buildSrc/src/test/kotlin/io/spine/gradle/publish/IncrementGuardTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@

package io.spine.gradle.publish

import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.shouldBe
import io.spine.gradle.publish.IncrementGuard.Companion.localPublishPlanned
import io.spine.gradle.publish.IncrementGuard.Companion.mustVerify
import io.spine.gradle.publish.IncrementGuard.Companion.shouldCheckVersion
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.publish.maven.tasks.PublishToMavenLocal
import org.gradle.testfixtures.ProjectBuilder
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -76,4 +83,103 @@ class IncrementGuardTest {
shouldCheckVersion(null, null) shouldBe false
}
}

@Nested
inner class `actually run the check` {

@Test
fun `on a CI pull request to a protected branch`() {
mustVerify(ciPullRequest = true, onCi = true, localPublish = false) shouldBe true
}

@Test
fun `on a local build that publishes to Maven Local`() {
mustVerify(ciPullRequest = false, onCi = false, localPublish = true) shouldBe true
}
}

@Nested
inner class `skip the check` {

@Test
fun `on a local build that does not publish`() {
mustVerify(ciPullRequest = false, onCi = false, localPublish = false) shouldBe false
}

@Test
fun `on a CI build that publishes to Maven Local outside a protected-branch PR`() {
// E.g. a push to `master` or a tag build running integration tests: the
// version is already published, so re-verifying it would fail the build.
mustVerify(ciPullRequest = false, onCi = true, localPublish = true) shouldBe false
}
}

@Nested
inner class `detect a Maven Local publish` {

@Test
fun `for the task's own project`() {
val project = guardedProject()
val publish = project.tasks
.register("publishFooPublicationToMavenLocal", PublishToMavenLocal::class.java)
.get()

localPublishPlanned(listOf(publish), project) shouldBe true
}

@Test
fun `but not when only a sibling project publishes`() {
val root = ProjectBuilder.builder().build()
val lib = ProjectBuilder.builder().withParent(root).withName("lib").build()
val app = ProjectBuilder.builder().withParent(root).withName("app").build()
app.pluginManager.apply("maven-publish")
val appPublish = app.tasks
.register("publishFooPublicationToMavenLocal", PublishToMavenLocal::class.java)
.get()

localPublishPlanned(listOf(appPublish), lib) shouldBe false
}
}

@Nested
inner class `make 'checkVersionIncrement' a dependency of` {

@Test
fun `the 'check' task`() {
val project = guardedProject()
val check = project.tasks.getByName("check")

check.dependencyNames() shouldContain IncrementGuard.taskName
}

@Test
fun `every Maven Local publishing task`() {
val project = guardedProject()
val localPublish = project.tasks.register(
"publishFooPublicationToMavenLocal",
PublishToMavenLocal::class.java
).get()

localPublish.dependencyNames() shouldContain IncrementGuard.taskName
}
}
}

/**
* Creates a project with the `base` plugin (for the `check` task), the
* `maven-publish` plugin (for [PublishToMavenLocal] tasks), and [IncrementGuard]
* applied.
*/
private fun guardedProject(): Project {
val project = ProjectBuilder.builder().build()
project.pluginManager.apply("base")
project.pluginManager.apply("maven-publish")
project.pluginManager.apply(IncrementGuard::class.java)
return project
}

/**
* Obtains the names of the tasks this task directly depends on.
*/
private fun Task.dependencyNames(): Set<String> =
taskDependencies.getDependencies(this).map { it.name }.toSet()
Loading
Loading