Published: Sep 22, 2022|23 min read23 minutes read
For a long time, VirtusLab has been involved with Scala either via expertise, tooling or active development of the Scala compiler and its ecosystem. One of our goals was to limit the number of regressions found in the language while preserving its fast development pace. The solution to this problem involved the creation of Open Community Build – a testing system designed to find regressions in the Scala 3 compiler. It’s a test site for the compiler, and it is powered by the efforts of the whole Scala ecosystem. In the previous article of the Open Community Build series, we described how it works and how it helps compiler engineers test the latest Scala language versions. Make sure to check it out. It will help you understand the key concepts of this tool.
In this article, we will focus on the most significant part of Open CB – the Scala 3 community. We will start by explaining what type of problems the tool tries to solve. We will also guide you on how you can help us ease this process if you’re a maintainer of some Scala 3 library. Lastly, we will tell you how you can contribute to the Open CB and the Scala 3 compiler team.
Compatibility with older versions of a software is one of the crucial aspects for long-term maintenance of any project. When your product is already in production, you don’t want to risk the loss of the precious data when your user would upgrade to a newer version. Similarly, when upgrading library dependencies, you don’t want to spend a long time on resolving compilation issues just to get a couple of new features or bug fixes. To make a choice of upgrading easier, most of the libraries are following the semantic versioning principle. In this approach, the version literal contains information allowing one to predict if the upgrade might cause some problems.
Most of the libraries in the Scala ecosystem are already following the semantic versioning.
However, it is not only limited to libraries. Scala 3 follows it as well. There are two different types of compatibility that need to be preserved by the language: binary compatibility and source compatibility.
If you’d like to learn more to preserve compatibility in your libraries you can check out a recording of Sébastien Doeraene’s lecture from the Sphere.it conference.
Designing libraries for source and binary compatibility
Binary compatibility means that outputs of the compilation – Tasty and ClassFile, can be consumed by other versions of the compiler. All versions of Scala 3 are only guaranteed to be backward compatible. You can use artifacts produced by Scala 3.1.x using Scala 3.2.x, but not the other way around. Each new compiler version should be able to consume artifacts produced by its previous releases. This implies that:
The format and serialization of Tasty and Bytecode need to be stable. Both of these formats allow only the addition of new constructs.
The standard library of Scala shall not remove publicly available methods. Otherwise, a reference to a no longer existing class or method introduced in an older compiler version will result in compilation or runtime errors. This type of API compatibility is enforced by the static analysis of Scala artifacts using MiMa.
Both of these areas are well-defined. This makes it relatively easy to maintain and detect regressions. The Open Community Build is not designed to ensure binary compatibility of Scala 3. Instead, it targets finding source compatibility regressions.
Two versions of a language are source-compatible if they can successfully compile the same, unmodified source code. What’s important, the semantics of the produced program should remain unchanged. From the software development perspective, that’s the most crucial aspect of the project’s maintenance. Scala libraries are very often cross-published for multiple versions of Scala. Not only for the latest Scala 3 but also for the well-established Scala 2.13, 2.12, and even the legacy Scala 2.11. All of these significant language versions are not binary compatible, except for Scala 2.13 and Scala 3. However, thanks to its backward source compatibility, most parts of the code can be safely compiled and published without needing changes. This behavior is crucial when migrating your code to newer versions of Scala. For example, it does not force you to use the new syntax but allows you to introduce it incrementally. Unfortunately, ensuring source compatibility is a way more complex topic, especially in the scope of language and the compiler.
Multiple different components play a significant role in ensuring your code’s semantics will not change. Scala is one of the most flexible and complex commercially used languages. The Scala type system is one of the most sophisticated in any programming language. In part because it combines comprehensive ideas from functional programming and object-oriented programming. What’s more, it’s still evolving. A good example might be type matching capabilities and kind polymorphism added in Scala 3. Also, a currently experimental extension to the language in the form of capture calculus might lead to discovering an entirely new way of interacting with the language.
However, even well-established features of the Scala language might still be faulty. Most features like the automatic type inference or implicit arguments for methods are typically viewed as stable features of the language. Like in any other program, new improvements to the compiler might easily break these well-established parts of the language.
Let’s take a closer look at one of the regressions found and fixed thanks to the Open Community Build. The mentioned issue used three main language features with which you’re probably familiar. The specific combination of them leads to failures in one of the latest versions of the compiler:
8 ("ABCD": Seq[Char]) isRotationOf "DAB" // ok (1)
9 wrapString("ABCD") isRotationOf "DAB" // ok (2)
10 "ABCD" isRotationOf "DAB" // error, no such method (3)
The use case of the reproduced bug was to add an additional method to all classes derived from `Seq` – the most general type of all the sequential data structures in Scala. This includes both custom implementations of this type and the ones shipped with the Scala standard library. It was done using a dedicated extension method with a strong type bound.
This worked well for all generic types with a single exception. It failed to work with Strings. An unaware reader might interpret what happened as correct behaviour. String on its own is not a Scala collection type but rather a standalone type comping from the Java standard library.
That is why implicit conversion was also involved in this bug. The standard library of Scala defines a class known as `scala.collection.immutable.WrappedString`, defined as `Seq[Char]`. Most of the operations on Strings that are not defined on Java String are exposed by this type. The automatic conversion from String to WrappedString is possible due to the conversion method `wrapString`. This conversion is a part of the always included Predef file, making it always available without explicit import statements.
The compiler had no problems finding the extension method when provided with some hints. For example, when we specified implicitly (1) or explicitly (2) that it should convert String to WrappedString. The Scala compiler could also correctly apply the extension method even without these hints (3) in some of its older versions. That means that the compiler has broken source compatibility.
This regression was introduced in one of the patch versions of the compiler. This situation was dangerous for Scala users. If the project’s codebase contains tens of similar problems, when code suddenly starts to fail to compile, it might discourage users from upgrading to the latest version of the compiler. This also implies that they would be unable to utilise security and performance improvements.
What’s even worse, problems like the one presented above might also hit the library authors. Users might discontinue using the problematic library, forcing advanced features. Instead, they might switch to alternative projects.
Our goal, as the maintainers of Scala language, is to encourage you to experiment with the language. Using the Open Community Build, we can ensure that your library will not suddenly break with the upcoming versions of Scala. However, to make that happen, we also need your help.
The Community Build is powered by the efforts of the whole Scala ecosystem. Every published library is a unique test site for the compiler. Even though we tried to make it resilient, we cannot ensure that every published library can be tested with this tool. Currently, it can operate on about 84% of published Scala 3 libraries. If you also publish a library of your own, you can help us. With your help, we should be able to increase these numbers.
If you want to know if your project is already part of the build, try to find it in one of the latest summaries of the runs or directly on the Open CB site. Let’s see what type of improvements you can introduce.
Make sure we can find your project
The first and most important step is ensuring that your libraries are listed in Scaladex. If you’re unfamiliar with Scaladex, it’s an online database of all published Scala libraries. The Scala Center created this platform to help library authors promote their creations. It uses metadata from POM files retrieved from Maven Central, aka Sonatype. The gathered information is later post-processed, allowing for a quick lookup of the most interesting information about your library. It lists published artefacts, their versions and links to the GitHub repository. All of this information is crucial for the Open CB workflow. We rely on Scaladex as our only reliable list of Scala projects. If your library is missing, we will not be able to include it in the build. In such cases, make sure to check out the Scaladex FAQ or contact the maintainers of Scaladex.
Publish your library with the correct metadata
Published libraries may change the domain under which they’re published. They can also be extracted from the main library into a dedicated standalone project. However, whenever you perform this type of change, make sure to update the metadata of the published jar. What’s most important for us is to update the coordinates of your projects. They should always point to the git repository, where we would find the sources used for compilation. As a good example of why it’s important, let’s take a look at the POM file of Spray JSON. This single Spray module was moved to a dedicated repository and cross-published for Scala 3. However, the SCM metadata set in the build was still pointing to the previous repository. This misconfiguration would later lead to building failures, as we would not be able to find the Spray-JSON project in the cloned repository.
Use tags when releasing a new version of a project
A good practice when publishing a library is creating a tag for the last commit used to build your project. I can help you with comparing changes between the two releases. Creating a tag helps us choose the correct revision of your repository for Open CB. One of the core ideas for Community Build is to build the project using its last stable version. The only way to do this is by comparing a list of published versions retrieved from the Scaladex with the list of tags found in your repository. If you published the library with version 1.2.3-RC4, and we will be able to find a tag containing this exact version, eg. “v1.2.4-RC4”, it’s a match!
Why do we want to build your project at its state during the latest release instead of the current one? It’s quite simple – the main branch of your project might not be stable. Using a continuous integration process for testing each change before merging it into the main branch of your project is a well-established practice. However, we cannot ensure this process will be followed in all projects. Also, there is a risk that some new features of the tested project might not yet be stable. We don’t want any false negative results to spoil the build results due to spuriously failing tests of your project. After all, each detected failure results in a manual inspection of the project logs. This takes time, and our resources are unfortunately limited. A lower number of unexpected failures would allow us to prioritise the most critical issues.
We strongly recommend using build tool plugins like sbt-release. Its usage should take care of the issues mentioned above.
Use future-proof conditions when checking the Scala version
Some of the most common issues we find when testing community projects are limited checks for the Scala version in your builds. Every project which is cross-published for multiple versions of Scala might need a different list of library dependencies or a different set of options passed to the compiler. In sbt, it’s typically handled by one of two approaches:
Comparing the whole Scala version literally:
1someSetting :=
2 if(scalaVersion.value == “3.1.3”) someValue
3 else if (scalaVersion.value == Scala3) someOtherValue
4 else defaultValue
Matching on the results of the partial version:
1 someSetting := CrossVersion.partialVersion(scalaVersion.value) match {
2 case Some((3, 2)) => someScala3Value
3 case Some((2, 13)) => someScala2Value
4 }
Each approach has some flaws. Let’s start with the one using pattern matching. In this case, we expect the project uses either Scala 3.2.x or Scala 2.13.x. It would work perfectly fine for your project, but it might have severe implications for the Community Build. As soon as we would start working on Scala 3.3.0-RC1 and try to test the project using Open CB, it would fail. A partial version would evaluate to `Some((3,3))`. If it wasn’t covered, it would lead to throwing an exception.
Also, be aware of using a similar method called CrossVersion.scalaApiVersion. Even though it seems to do the same thing and evaluate the same type, it can produce completely different results.
When using the partial version approach, make sure not to be too specific unless reeded. Replacing the snippet with the following one would help us test your project more easily and would not cause a hassle with changing this match when upgrading to a new compiler version.
1 someSetting := CrossVersion.partialVersion(scalaVersion.value) match {
2 case Some((3, 1)) => someVerySpecificScala31Value
3 case Some((3, _)) => someScala3Value
4 case Some((2, _)) => someScala2Value
5 }
We recommend you use a similar approach when comparing version literals. When you check whether you’re currently building for Scala 2 or Scala 3, check only the prefix instead of the exact value.
1someSetting :=
2 if (scalaVersion.value.startsWith(“3.1.”)) someVerySpecificScala31Value
3 else if (scalaVersion.value.startsWith(“3.”)) someScala3Value
4 else defaultScala2Value
For convenience, you can wrap them into the dedicated method.
If you still need to refer to the Scala version in your build, assign the literal to a variable using one of the supported variable names. This will allow us to replace the assigned value with the tested version of Scala.
Create a configuration file in your project root directory
Last but not least, you can use a dedicated configuration file to tweak some of the parts of testing your project. All you need to do is to place a file with a HOCON configuration named “scala3-community-build.conf” in your project’s root directory. In the GitHub repository of community build, you can find a reference file describing additional configuration you can provide us. Let’s cover some of the most important ones.
Selecting JDK version
Most of the projects included in the Community Build use GitHub actions as the main CI platform. We can analyse the workflow files to find a minimal version of JDK that can be successfully used. In some cases, however, you might use an older Java version for workflows other than the compilation of your project.
You can explicitly provide us with which JDK version we should use in the `java.version` setting. You can choose only between the LTS versions of Java: 8, 11 (default), and 17, eg. `java.version = 8`.
Whenever possible choose the minimal required version. Remember that artefacts published by your library might be used for other projects within the Open CB run. This means it can affect them, as they might not be able to consume bytecode produced by a newer version of the compiler.
Choosing testing strategy
By default, we try to compile your library and run the provided unit tests. It’s crucial to ensure that the semantics of the code produced by the compiler has not changed.
However, multiple projects might require a dedicated environment to run the test successfully. For example, they might need a dedicated database or to start a testing container. We cannot always allow for that. If that’s the case for your project, consider only compiling or disabling the tests.
You can use the `tests` setting to set the default behaviour for the whole project and use one of three available modes:
full – tests will be compiled and executed (default)
compile-only – tests will only be compiled
disabled – tests will be ignored
If you want to override only some of the projects, that option is also available. You can use a dedicated `projects.overrides` setting to do it. For example:
1tests = compile-only // default for the project
2projects.overrides {
3 subModuleA.tests = full // compile and execute tests in project named `subModuleA`
4 benchmarks { tests = disabled } // ignore tests in project name `benchmarks`
Sometimes your build tool might require some additional flags to build or test your project successfully. For both mill and sbt, you can pass a list of necessary settings. That can contain memory limit settings, system properties for configuration, or other accepted flags.
In the case of the sbt, you can also pass additional commands that need to be evaluated before the start of the build. They can be used to prepare your sources, filter out some tests, or configure additional settings.
1sbt.commands = [
2 “genAdditionalSources”,
3 "set every Test/classLoaderLayeringStrategy := ClassLoaderLayeringStrategy.Flat",
We encourage you to create a configuration file inside your project. It helps us ensure your project is always up to date. However, in case of its absence, we might try to use our own configuration for your project, which is kept in our repository. It is structured similarly but contains multiple configurations instead of only the one for your project. If your project is not listed there as well, we will use default values. Our goal in the future is to eliminate the internal configuration and its maintenance burden completely.
In the future, we will also try to automate the process of notifying you about recurring failures in the community build. This process might also temporarily suspend your project if no failures occur due to changes in the compiler. In such a case, it would be worth monitoring your project and tuning spuriously failing tasks.
Let us know if you use compiler plugins
Compiler plugins are a perfect solution for extending the language or introducing non-trivial changes to code. Unfortunately, they’re quite problematic to maintain as a part of the Community Build. Compiler plugins are cross-compiled for each exact Scala version instead of the binary version of the language. This means that if your project uses compiler plugins, the plugin itself needs to be compiled and published as part of the Open CB run before it can be used to compile your project. What’s even worse is that compiler plugins are typically not included in the project’s list of dependencies. This means that we have no easy access to information about which plugins are needed.
We are currently working on overcoming this limitation, as the compiler plugins in Scala 3 are starting to get more popular. Static analysis of your project files might not be enough to detect their usage. That’s because they can be introduced via sbt plugins. Right now, the build tool seems to be the only reliable solution to get this information. Unfortunately, starting the sbt for a project might take a significant amount of time, even up to tens of seconds. Creating a build tool to retrieve this information for 1000 projects would take way too long.
As a compromise, we’re experimenting with an additional boolean setting in the project configuration file. If your library is using any Scala 3 compiler plugin, add the following entry to your project. It will help detect which projects require additional analysis using build tool outputs.
1 uses-compiler-plugins = true
2
Try to run Open CB on your machine
To test how your project would behave in the Open Community Build, you can try to run it locally on your machine. You only need two programs on your device: scala-cli and minikube. The first one runs the dedicated CLI script. The second one starts containers with the build to ensure that your local environment will not disrupt it. With the dependencies installed, run the following command in your console using the “run” command, followed by the name of your project and the version of Scala used for the test. For example:
You will then be able to watch the progress of the builds via the file, which gets forwarded logs from the container.
Try to help us minimize the found regressions
Another way you can be a part of the Open Community Build is by being actively involved in fixing found regressions in the compiler. However, we don’t expect you to deep dive into the internals of the compiler.
Instead, you can start by trying to minimize some of the projects that failed in the previous couple of days with the new nightly snapshots or release candidates of the compiler. In the GitHub repository of the Scala 3 compiler, you can frequently find issues containing a list of projects that failed to build recently. Look at some non-reproduced ones, browse through the logs, and challenge yourself to reproduce one of them in the form of a self-contained code. Minimizing a bug is the first step in resolving it. This type of contribution can help the whole compiler team and improve our beloved language.
When you feel confident enough, you might try to extend your knowledge about the compiler from the Scala 3 Compiler Academy videos. You can also participate in issue sprees organized by the Scala Center. At this recurring meeting, you will be able to fix some of the problems within the Scala 3 compiler with the help of the compiler engineers.
We at VirtusLab strongly encourage experimenting with Scala and finding new solutions for all kinds of problems. If you’ve created something uncommon using this language, share it with the world. Even if you were to be the only user of your library, you might help the whole community by preventing the introduction of a new type of regression to Scala. Finally, you might start getting involved in the process or improving Scala directly. All of these would make Scala 3 more stable and increase its popularity worldwide.
If your organization is using Scala 3 on its proprietary code but would like to test it against changes in the compiler, get in touch with our team. We can also help you with migration and provide you with subscription support. Drop an email at scala@virtuslab.com.
If your organization is using Scala 3 on its proprietary code but would like to test it against changes in the compiler, get in touch with our team. We can also help you with migration and provide you with subscription support. Drop us an email at scala@virtuslab.com. You can also check out our Scala offer here: