From aa38262f6fca92bdf5bf5e4f72b86b36a062301a Mon Sep 17 00:00:00 2001 From: Rahul Bhonsale Date: Wed, 17 Jun 2026 09:13:29 +0200 Subject: [PATCH 1/3] Cross-build twinagle-scalapb-plugin for sbt 1.x and sbt 2.x The plugin now cross-publishes for both sbt 1.x and sbt 2.x. The build keeps an sbt 1.x launcher and produces both artifacts via crossScalaVersions [2.12, 3] + pluginCrossBuild/sbtVersion (2.12 -> sbt 1.x, Scala 3 -> sbt 2.0.0), matching the sbt-protoc / sbt-assembly pattern. sbt-projectmatrix has no sbt 2 release, so the "build with an sbt 2 launcher" path is not viable yet. Key changes: - build.sbt: split scala3LTS (3.3.7, runtime stays on LTS) from scala3 (3.8.4, codegen + plugin Scala-3 axis); add plugin crossScalaVersions, pluginCrossBuild/sbtVersion, scriptedSbt; add sbt2-compat 0.1.0. - Bump sbt-protoc 1.0.7 -> 1.1.0-RC1 (only version with an sbt-2 variant) in both the plugin module and project/plugins.sbt. - Migrate scalapb to 1.0.0-alpha.5: stable 0.11.x compilerplugin_3 is compiled against protoc-bridge_2.13, whose ProtocCodeGenerator.$init$ ABI is absent in the protoc-bridge_3 that sbt-protoc's sbt-2 build pulls (NoSuchMethodError at codegen time). Only scalapb 1.0.0-alpha.4+ is natively protoc-bridge_3. Exclude scalapb-json4s's transitive scalapb-runtime (json4s only ships alpha.1) so consumers get a single coherent alpha.5. - Source fixes for Scala 3 / -Werror: -Xfatal-warnings -> -Werror on Scala 3; drop private[this]; drop explicit Seq[Def.Setting[_]] return type; explicit InclExclRule element type in excludeDependencies. - e2e scripted test: pin scalaVersion 2.13 (resolves exact-match artifacts on both axes); delete project/build.properties so it runs on both sbt axes; @unchecked on partial pattern bindings; drop now-useless @nowarn. - CI: JDK 8 -> 17 (sbt 2.0.0 requires 17); run `+test +publishLocal +plugin/scripted` (bare scripted only covers sbt 1.x). - Docs: note dual sbt support, JDK 17, and the corrected test command. Verified: +test (50 tests, all axes) and +plugin/scripted (e2e passes under both sbt 1.11.7 and sbt 2.0.0) green; scalafmtCheckAll clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 6 +-- README.md | 17 ++++++- build.sbt | 47 +++++++++++++++---- .../codegen/TwinagleServicePrinter.scala | 36 +++++++------- docs/index.md | 2 + plugin/README.md | 32 +++++++++---- .../soundcloud/twinagle/plugin/Twinagle.scala | 14 ++++-- plugin/src/sbt-test/generator/e2e/build.sbt | 2 + .../generator/e2e/project/build.properties | 1 - .../scala/proto/test/OptionalFieldsSpec.scala | 2 - .../example/haberdasher/HaberdasherSpec.scala | 6 +-- project/plugins.sbt | 4 +- 12 files changed, 117 insertions(+), 52 deletions(-) delete mode 100644 plugin/src/sbt-test/generator/e2e/project/build.properties diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 641564af..bbf7f492 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,14 +18,14 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install JDK 1.8 + - name: Install JDK 17 uses: actions/setup-java@v4 with: - java-version: 8 + java-version: 17 distribution: temurin - uses: sc-actions-forks/setup-sbt@96cf3f09dc501acdad7807fffe97dba9fa0709be #v1.1.5 - name: Run tests - run: sbt +test scripted + run: sbt +test +publishLocal +plugin/scripted - name: Lint run: sbt scalafmtCheckAll - name: Release diff --git a/README.md b/README.md index 106b94b3..ebdcc545 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,18 @@ Thanks for your interest in Twinagle, we're welcome your contributions! For larger changes, please open an issue to discuss them before spending lots of time implementing things. For small changes, hack away and submit a pull request. -Please ensure that `sbt scalafmtCheckAll +test scripted` passes when submitting code changes. +Please ensure that `sbt scalafmtCheckAll +test +publishLocal +plugin/scripted` passes when submitting code changes. + +# sbt 1.x and sbt 2.x + +The `twinagle-scalapb-plugin` is cross-built for **both sbt 1.x and sbt 2.x**. +The build itself runs on an sbt 1.x launcher and cross-publishes both plugin +artifacts via `crossScalaVersions = [2.12, 3]` + `pluginCrossBuild / sbtVersion` +(Scala 2.12 → sbt 1.x, Scala 3 → sbt 2.0.0). This follows the pattern used by +sbt-protoc / sbt-assembly; see the +[sbt 2 plugin cross-building guide](https://www.scala-sbt.org/2.x/docs/en/changes/migrating-from-sbt-1.x.html#cross-building-sbt-plugins). + +**Building requires JDK 17** (sbt 2.0.0 will not run on JDK 8/11). # Notes @@ -24,4 +35,6 @@ Please ensure that `sbt scalafmtCheckAll +test scripted` passes when submitting `sbt compile` may be necessary. * In order to run the full test suite (i.e. the unit tests & the end-to-end tests - for code-generation) use `sbt +test scripted` + for code-generation) use `sbt +test +publishLocal +plugin/scripted`. The + `+plugin/scripted` runs the end-to-end test against **both** sbt 1.x and sbt 2.x; + bare `scripted` only exercises the sbt 1.x axis. diff --git a/build.sbt b/build.sbt index faaf5029..9d14c5c1 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,10 @@ import sbt.CrossVersion lazy val scala212 = "2.12.20" lazy val scala213 = "2.13.17" -lazy val scala3LTS = "3.3.7" +lazy val scala3LTS = "3.3.7" // runtime stays on LTS — it is the end-user library +lazy val scala3 = "3.8.4" // codegen + plugin Scala-3 axis (matches sbt 2.0.0 DSL) +lazy val sbt2 = "2.0.0" +lazy val sbt1 = "1.9.9" // pluginCrossBuild sbt version for the 2.12 axis (oldest sbt 1.x we support) lazy val commonSettings = List( scalaVersion := scala212, @@ -11,15 +14,20 @@ lazy val commonSettings = List( case Some((2, _)) => scalacOptions ++= Seq("-Xlint") case _ => () } + // Scala 3 renamed -Xfatal-warnings to -Werror (the old alias is itself deprecated). + val fatalWarnings = CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, _)) => "-Xfatal-warnings" + case _ => "-Werror" + } Seq( "-encoding", "utf8", "-deprecation", "-unchecked", - "-Xfatal-warnings" + fatalWarnings ) }, - Compile / console / scalacOptions --= Seq("-deprecation", "-Xfatal-warnings", "-Xlint"), + Compile / console / scalacOptions --= Seq("-deprecation", "-Xfatal-warnings", "-Werror", "-Xlint"), scalafmtOnCompile := true ) @@ -27,7 +35,7 @@ lazy val codegen = (project in file("codegen")) .settings( commonSettings, name := "twinagle-codegen", - crossScalaVersions := Seq(scala212, scala213, scala3LTS), + crossScalaVersions := Seq(scala212, scala213, scala3), libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % scalapb.compiler.Version.scalapbVersion, publishLocal := publishLocal.dependsOn(runtime / publishLocal).value ) @@ -37,8 +45,23 @@ lazy val plugin = (project in file("plugin")) .dependsOn(codegen) .settings( commonSettings, - name := "twinagle-scalapb-plugin", - addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.7"), + name := "twinagle-scalapb-plugin", + crossScalaVersions := Seq(scala212, scala3), + scalaVersion := scala212, + pluginCrossBuild / sbtVersion := { + scalaBinaryVersion.value match { + case "2.12" => sbt1 + case _ => sbt2 + } + }, + scriptedSbt := { + scalaBinaryVersion.value match { + case "2.12" => sbtVersion.value + case _ => sbt2 + } + }, + addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.1.0-RC1"), + addSbtPlugin("com.github.sbt" % "sbt2-compat" % "0.1.0"), buildInfoKeys := Seq[BuildInfoKey](version, scalaBinaryVersion), buildInfoPackage := "com.soundcloud.twinagle.plugin", buildInfoUsePackageAsPath := true, @@ -57,8 +80,16 @@ lazy val runtime = (project in file("runtime")).settings( libraryDependencies ++= { Seq( "com.twitter" %% "finagle-http" % "24.2.0" cross CrossVersion.for3Use2_13, - "com.thesamet.scalapb" %% "scalapb-runtime" % "0.11.20", - "com.thesamet.scalapb" %% "scalapb-json4s" % "0.12.1", + "com.thesamet.scalapb" %% "scalapb-runtime" % "1.0.0-alpha.5", + // scalapb-json4s has only published 1.0.0-alpha.1, which pulls scalapb-runtime alpha.1. + // Exclude its transitive scalapb-runtime so only our alpha.5 above remains: sbt's + // early-semver check otherwise flags the alpha.1 -> alpha.5 eviction as a hard conflict, + // and a per-dependency exclude (unlike dependencyOverrides) serializes into the published + // POM/ivy so downstream consumers inherit the coherent single version. + ("com.thesamet.scalapb" %% "scalapb-json4s" % "1.0.0-alpha.1") + .exclude("com.thesamet.scalapb", "scalapb-runtime_2.12") + .exclude("com.thesamet.scalapb", "scalapb-runtime_2.13") + .exclude("com.thesamet.scalapb", "scalapb-runtime_3"), "org.json4s" %% "json4s-native" % "4.0.7", "org.specs2" %% "specs2-core" % "4.20.9" % Test cross CrossVersion.for3Use2_13 ) diff --git a/codegen/src/main/scala/com/soundcloud/twinagle/codegen/TwinagleServicePrinter.scala b/codegen/src/main/scala/com/soundcloud/twinagle/codegen/TwinagleServicePrinter.scala index b46d7dc7..ef5d5570 100644 --- a/codegen/src/main/scala/com/soundcloud/twinagle/codegen/TwinagleServicePrinter.scala +++ b/codegen/src/main/scala/com/soundcloud/twinagle/codegen/TwinagleServicePrinter.scala @@ -9,24 +9,24 @@ final class TwinagleServicePrinter( ) { import implicits._ - private[this] val twitterUtil = "_root_.com.twitter.util" - private[this] val finagle = "_root_.com.twitter.finagle" - private[this] val finagleHttp = s"$finagle.http" - private[this] val twinagle = "_root_.com.soundcloud.twinagle" - - private[this] val Future = s"$twitterUtil.Future" - private[this] val Service = s"$finagle.Service" - private[this] val Filter = s"$finagle.Filter" - private[this] val Request = s"$finagleHttp.Request" - private[this] val Response = s"$finagleHttp.Response" - - private[this] val EndpointMetadata = s"$twinagle.EndpointMetadata" - private[this] val MessageFilter = s"$twinagle.MessageFilter" - private[this] val ClientEndpointBuilder = s"$twinagle.ClientEndpointBuilder" - private[this] val ServerBuilder = s"$twinagle.ServerBuilder" - private[this] val ProtoService = s"$twinagle.ProtoService" - private[this] val AsProtoService = s"$twinagle.AsProtoService" - private[this] val ProtoRpcBuilder = s"$twinagle.ProtoRpcBuilder" + private val twitterUtil = "_root_.com.twitter.util" + private val finagle = "_root_.com.twitter.finagle" + private val finagleHttp = s"$finagle.http" + private val twinagle = "_root_.com.soundcloud.twinagle" + + private val Future = s"$twitterUtil.Future" + private val Service = s"$finagle.Service" + private val Filter = s"$finagle.Filter" + private val Request = s"$finagleHttp.Request" + private val Response = s"$finagleHttp.Response" + + private val EndpointMetadata = s"$twinagle.EndpointMetadata" + private val MessageFilter = s"$twinagle.MessageFilter" + private val ClientEndpointBuilder = s"$twinagle.ClientEndpointBuilder" + private val ServerBuilder = s"$twinagle.ServerBuilder" + private val ProtoService = s"$twinagle.ProtoService" + private val AsProtoService = s"$twinagle.AsProtoService" + private val ProtoRpcBuilder = s"$twinagle.ProtoRpcBuilder" def generateServiceObject(m: ServiceDescriptor): String = { val serviceName = getServiceName(m) diff --git a/docs/index.md b/docs/index.md index bc8a280b..fe22cbc1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,8 @@ over http. ## Project setup To get started with twinagle, you'll need to add a plugin dependency to your project and enable the plugin for your build. +The plugin is published for both **sbt 1.x and sbt 2.x** — sbt resolves the matching +artifact automatically, so the same line works in either case. Add the folowing line to `project/plugins.sbt`: ```scala diff --git a/plugin/README.md b/plugin/README.md index cb6fb77c..ef84ece2 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,22 +1,34 @@ # Twinagle ScalaPB Plugin -To test the generator, within SBT: +This plugin is cross-built for **sbt 1.x and sbt 2.x**. The end-to-end test under +`plugin/src/sbt-test/generator/e2e` runs against both: on the Scala 2.12 axis it +launches sbt 1.x, and on the Scala 3 axis it launches sbt 2.0.0. + +## Running the generator tests + +To test the generator across both sbt axes, within sbt (requires JDK 17): ``` -> scripted +> +publishLocal +> +plugin/scripted ``` -This would publish your generator locally and run it over a test project -located under `codegen/src/sbt-test/generator/e2e`. +`+publishLocal` cross-publishes `twinagle-runtime`, `twinagle-codegen`, and both +plugin artifacts (`_2.12_1.0` and `_sbt2_3`) to your local ivy repo. `+plugin/scripted` +then publishes nothing further and runs the test project once per sbt axis. + +> Note: a bare `scripted` only runs the sbt 1.x axis. Use `+plugin/scripted` to +> cover sbt 2.x as well. + +## Iterating on the test project without republishing -Assuming that the plugin If you would like to test the test project without republishing the plugin -each time (assuming it does not change), then publish it locally using -`publishLocal`, then in `codegen/src/sbt-test` run SBT and pass it the version number, -for example: +If the plugin itself hasn't changed, publish it once and run the test project +directly, passing the version number: ``` -cd codegen/src/sbt-test/generator/e2e` -sbt -Dplugin.version=0.1.1-SNAPSHOT +sbt +publishLocal # note the version it prints, e.g. 1.4.6-SNAPSHOT +cd plugin/src/sbt-test/generator/e2e +sbt -Dplugin.version=1.4.6-SNAPSHOT > test ``` diff --git a/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala b/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala index 265a56a4..d92a2392 100644 --- a/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala +++ b/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala @@ -13,7 +13,10 @@ object Twinagle extends AutoPlugin { override def trigger: PluginTrigger = NoTrigger - override def projectSettings: Seq[Def.Setting[_]] = List( + // Return type is inherited from AutoPlugin.projectSettings. We avoid writing it + // explicitly because sbt 1 (Scala 2.12) types it as Seq[Setting[_]] while sbt 2 + // (Scala 3) uses Seq[Setting[?]], and `_` is a fatal deprecation under -Werror on 3.x. + override def projectSettings = List( scalapbCodeGeneratorOptions := { CrossVersion.partialVersion(scalaVersion.value) match { case Some((3, _)) => @@ -42,9 +45,14 @@ object Twinagle extends AutoPlugin { "com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % "protobuf" ), excludeDependencies ++= { + // Explicit InclExclRule element type: sbt 2 / Scala 3 no longer applies the implicit + // OrganizationArtifactName -> InclExclRule conversion that sbt 1 relied on here. CrossVersion.partialVersion(scalaVersion.value) match { - case Some((3, _)) => Seq("org.scala-lang.modules" % "scala-collection-compat_2.13") - case _ => Seq.empty + case Some((3, _)) => + Seq[sbt.librarymanagement.InclExclRule]( + "org.scala-lang.modules" % "scala-collection-compat_2.13" + ) + case _ => Seq.empty[sbt.librarymanagement.InclExclRule] } } ) diff --git a/plugin/src/sbt-test/generator/e2e/build.sbt b/plugin/src/sbt-test/generator/e2e/build.sbt index 76c075c6..ea4495e8 100644 --- a/plugin/src/sbt-test/generator/e2e/build.sbt +++ b/plugin/src/sbt-test/generator/e2e/build.sbt @@ -1,5 +1,7 @@ enablePlugins(Twinagle) +scalaVersion := "2.13.17" + scalacOptions ++= Seq( "-encoding", "utf8", diff --git a/plugin/src/sbt-test/generator/e2e/project/build.properties b/plugin/src/sbt-test/generator/e2e/project/build.properties deleted file mode 100644 index 72413de1..00000000 --- a/plugin/src/sbt-test/generator/e2e/project/build.properties +++ /dev/null @@ -1 +0,0 @@ -sbt.version=1.8.3 diff --git a/plugin/src/sbt-test/generator/e2e/src/test/scala/proto/test/OptionalFieldsSpec.scala b/plugin/src/sbt-test/generator/e2e/src/test/scala/proto/test/OptionalFieldsSpec.scala index 83c5c449..fccf6136 100644 --- a/plugin/src/sbt-test/generator/e2e/src/test/scala/proto/test/OptionalFieldsSpec.scala +++ b/plugin/src/sbt-test/generator/e2e/src/test/scala/proto/test/OptionalFieldsSpec.scala @@ -15,10 +15,8 @@ class OptionalFieldsSpec extends Specification { Future.value(req) } - @annotation.nowarn private def iTakeAnOptionInt[T](a: T)(implicit ev: T =:= Option[Int]): Unit = () - @annotation.nowarn private def iTakeAnOptionString[T](a: T)(implicit ev: T =:= Option[String]): Unit = () } diff --git a/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala b/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala index b745e67f..1c971090 100644 --- a/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala +++ b/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala @@ -36,7 +36,7 @@ class HaberdasherSpec extends Specification { } "produces TwinagleExceptions for error responses" in { - val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry) + val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry): @unchecked ex.code ==== ErrorCode.InvalidArgument } @@ -59,7 +59,7 @@ class HaberdasherSpec extends Specification { } "produces TwinagleExceptions for error responses" in { - val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry) + val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry): @unchecked ex.code ==== ErrorCode.InvalidArgument } @@ -78,7 +78,7 @@ class HaberdasherSpec extends Specification { } "produces TwinagleExceptions for error responses" in { - val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry) + val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry): @unchecked ex.code ==== ErrorCode.InvalidArgument } diff --git a/project/plugins.sbt b/project/plugins.sbt index 88b4bdcd..221be3bc 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -8,7 +8,7 @@ addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") libraryDependencies ++= Seq( "org.scala-sbt" %% "scripted-plugin" % sbtVersion.value, - "com.thesamet.scalapb" %% "compilerplugin" % "0.11.20" + "com.thesamet.scalapb" %% "compilerplugin" % "1.0.0-alpha.5" ) // only necessary so we can generate protos for tests -addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.7") +addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.1.0-RC1") From abdba679993e2ecd01bfa15918712949897914aa Mon Sep 17 00:00:00 2001 From: Rahul Bhonsale Date: Wed, 17 Jun 2026 09:26:29 +0200 Subject: [PATCH 2/3] Address Gemini review: fix -Xlint dead config; idiomatic throwA in e2e - build.sbt: the scalacOptions block discarded the inner `scalacOptions ++= Seq("-Xlint")` Setting, so -Xlint was never actually applied on Scala 2. Refactor to return a single Seq per axis; -Xlint is now genuinely active on 2.12/2.13 (sources verified lint-clean) and -Werror on Scala 3. - HaberdasherSpec: replace the `: @unchecked` refutable pattern bindings with Specs2 `throwA[TwinagleException].like { case ex: TwinagleException => ... }` (the explicit pattern type is required so `ex.code` resolves). Drops the now unused `Throw` import. Verified: +test (50 tests, all axes) and +plugin/scripted (e2e passes under both sbt 1.11.7 and sbt 2.0.0) green; scalafmtCheckAll clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.sbt | 18 ++++------------- .../example/haberdasher/HaberdasherSpec.scala | 20 +++++++++---------- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/build.sbt b/build.sbt index 9d14c5c1..39f0de67 100644 --- a/build.sbt +++ b/build.sbt @@ -10,22 +10,12 @@ lazy val sbt1 = "1.9.9" // pluginCrossBuild sbt version for the 2.12 axis ( lazy val commonSettings = List( scalaVersion := scala212, scalacOptions ++= { + val common = Seq("-encoding", "utf8", "-deprecation", "-unchecked") CrossVersion.partialVersion(scalaVersion.value) match { - case Some((2, _)) => scalacOptions ++= Seq("-Xlint") - case _ => () + // Scala 3 renamed -Xfatal-warnings to -Werror (the old alias is itself deprecated). + case Some((2, _)) => common ++ Seq("-Xlint", "-Xfatal-warnings") + case _ => common ++ Seq("-Werror") } - // Scala 3 renamed -Xfatal-warnings to -Werror (the old alias is itself deprecated). - val fatalWarnings = CrossVersion.partialVersion(scalaVersion.value) match { - case Some((2, _)) => "-Xfatal-warnings" - case _ => "-Werror" - } - Seq( - "-encoding", - "utf8", - "-deprecation", - "-unchecked", - fatalWarnings - ) }, Compile / console / scalacOptions --= Seq("-deprecation", "-Xfatal-warnings", "-Werror", "-Xlint"), scalafmtOnCompile := true diff --git a/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala b/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala index 1c971090..d607a565 100644 --- a/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala +++ b/plugin/src/sbt-test/generator/e2e/src/test/scala/twitch/twirp/example/haberdasher/HaberdasherSpec.scala @@ -2,7 +2,7 @@ package twitch.twirp.example.haberdasher import com.soundcloud.twinagle.{ErrorCode, ServerBuilder, TwinagleException} import com.twitter.finagle.{Service, http} -import com.twitter.util.{Await, Future, Throw} +import com.twitter.util.{Await, Future} import org.specs2.mutable.Specification class HaberdasherSpec extends Specification { @@ -36,9 +36,9 @@ class HaberdasherSpec extends Specification { } "produces TwinagleExceptions for error responses" in { - val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry): @unchecked - - ex.code ==== ErrorCode.InvalidArgument + Await.result(client.makeHat(Size(-1))) must throwA[TwinagleException].like { case ex: TwinagleException => + ex.code ==== ErrorCode.InvalidArgument + } } } @@ -59,9 +59,9 @@ class HaberdasherSpec extends Specification { } "produces TwinagleExceptions for error responses" in { - val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry): @unchecked - - ex.code ==== ErrorCode.InvalidArgument + Await.result(client.makeHat(Size(-1))) must throwA[TwinagleException].like { case ex: TwinagleException => + ex.code ==== ErrorCode.InvalidArgument + } } } @@ -78,9 +78,9 @@ class HaberdasherSpec extends Specification { } "produces TwinagleExceptions for error responses" in { - val Throw(ex: TwinagleException) = Await.result(client.makeHat(Size(-1)).liftToTry): @unchecked - - ex.code ==== ErrorCode.InvalidArgument + Await.result(client.makeHat(Size(-1))) must throwA[TwinagleException].like { case ex: TwinagleException => + ex.code ==== ErrorCode.InvalidArgument + } } } } From 76ee32f1fef2a31b46b1be5673034061a82bdc49 Mon Sep 17 00:00:00 2001 From: Rahul Bhonsale Date: Wed, 17 Jun 2026 09:41:16 +0200 Subject: [PATCH 3/3] Address Gemini round 2: idiomatic ExclusionRule in plugin excludeDependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Twinagle.scala: use ExclusionRule("org", "name") instead of the verbose `Seq[sbt.librarymanagement.InclExclRule](org % name)` form. - build.sbt: keep the explicit _2.12/_2.13/_3 suffixes on the scalapb-json4s scalapb-runtime exclude, and document why. Gemini suggested CrossVersion.binary, but that serializes to the suffix-less artifact id in the published POM and fails to match scalapb-runtime_ in a downstream consumer — the scripted e2e (consumer's-eye view) reproduces the version conflict with it, so the explicit suffixes are required. - Note: Gemini's "Scala 3.8.4 does not exist" finding is a stale-knowledge false positive; CI resolves 3.8.4 and passes. Verified: +test (50, all axes), +plugin/scripted (e2e passes under sbt 1.11.7 and sbt 2.0.0), scalafmtCheckAll all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.sbt | 5 +++++ .../com/soundcloud/twinagle/plugin/Twinagle.scala | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/build.sbt b/build.sbt index 39f0de67..24ca9bad 100644 --- a/build.sbt +++ b/build.sbt @@ -76,6 +76,11 @@ lazy val runtime = (project in file("runtime")).settings( // early-semver check otherwise flags the alpha.1 -> alpha.5 eviction as a hard conflict, // and a per-dependency exclude (unlike dependencyOverrides) serializes into the published // POM/ivy so downstream consumers inherit the coherent single version. + // + // The Scala-binary suffixes (_2.12/_2.13/_3) are spelled out explicitly rather than via + // CrossVersion.binary: exclusions match on the literal published artifactId, and a + // CrossVersion.binary rule serializes to the suffix-less "scalapb-runtime", which does + // NOT match "scalapb-runtime_2.13" in a downstream consumer's POM resolution. ("com.thesamet.scalapb" %% "scalapb-json4s" % "1.0.0-alpha.1") .exclude("com.thesamet.scalapb", "scalapb-runtime_2.12") .exclude("com.thesamet.scalapb", "scalapb-runtime_2.13") diff --git a/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala b/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala index d92a2392..791d2c12 100644 --- a/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala +++ b/plugin/src/main/scala/com/soundcloud/twinagle/plugin/Twinagle.scala @@ -45,14 +45,14 @@ object Twinagle extends AutoPlugin { "com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % "protobuf" ), excludeDependencies ++= { - // Explicit InclExclRule element type: sbt 2 / Scala 3 no longer applies the implicit - // OrganizationArtifactName -> InclExclRule conversion that sbt 1 relied on here. + // ExclusionRule directly (rather than `org % name`): the implicit + // OrganizationArtifactName -> InclExclRule conversion sbt 1 relied on here is + // gone in sbt 2 / Scala 3. The _2.13 suffix is the literal artifact name finagle + // drags in via for3Use2_13, not a cross-versioned suffix. CrossVersion.partialVersion(scalaVersion.value) match { case Some((3, _)) => - Seq[sbt.librarymanagement.InclExclRule]( - "org.scala-lang.modules" % "scala-collection-compat_2.13" - ) - case _ => Seq.empty[sbt.librarymanagement.InclExclRule] + Seq(ExclusionRule("org.scala-lang.modules", "scala-collection-compat_2.13")) + case _ => Seq.empty } } )