Automating .net library versioning - Syntactic Versioning.

This is an entry for the F# Advent Calendar 2019. It is the first in a three part series exploring a way of automating .net library versioning. This post describes a library written in F# which was the product of a blog post from the FsAdvent 2016.

Semantic versioning is a popular method for versioning software libraries. It describes three possible types of change of software libraries, Major, Minor and Patch:

  • Major - a change of expected behaviour or a non-backwards compatible change in the API.
  • Minor - a backwards compatible change that adds functionality to a library.
  • Patch - a backwards compatible change that fixes a bug or other small change.

An article from the FsAdvent 2016 describes a method of detecting api version magnitude changes and this has been turned into a nuget package called Syntactic Versioning and a dotnet tool called synver.

Syntactic Versioning

Syntactic Versioning is a library that can be used to determine the public API differences between .dll files. It is aware of the API surface change rules of semantic versioning and can be used on different builds of a .NET library to determine version magnitude changes.

MyProject

If a library MyProject has the following class MyClass with method MyMethod in it:

namespace MyProject
{
    public class MyClass 
    {
        public void MyMethod(string stringArg)
        {
        }
    }
}

Syntactic Versioning uses reflection (or a decompiler) to map the public classes and methods of MyProject into a Set<Tuple<string, string>>. The above code is represented by a Set containing the following tuples:

("MyProject",         "MyProject (Namespace)")
("MyProject.MyClass", "(Instance of MyProject.MyClass).MyMethod : stringArg:System.String -> System.Void")
("MyProject.MyClass", "MyProject.MyClass (.NET type: Class and base: System.Object)")
("MyProject.MyClass", "new MyProject.MyClass : System.Void -> MyProject.MyClass")

We can see the text representations of the namespace, MyMethod, class and default constructor respectively.

Syntactic Versioning uses source and target representations of the API and then can derive the magnitude change using the following rules:

  • Major - if any tuples in the source set are not present in the target set, this is breaking the APIs backwards compatibility and therefore a Major change
  • Minor - if any new tuples are present in the target set then this is extending functionality and is a Minor change
  • Patch - if the sets are the same then this is a Patch change

If MyClass then has another method MyMethod2 added to it:

public class MyClass 
{
    public void MyMethod(string stringArg)
    {
    }
    
    public void MyMethod2(bool boolArg)
    {
    }
}

Syntactic versioning can be used on the source/target built .dll files and will detect a new tuple, not present in the source set:

("MyProject.MyClass", "(Instance of MyProject.MyClass).MyMethod2 : boolArg:System.Boolean -> System.Void")

This is therefore a Minor magnitude change. Whereas, if MyMethod changes the type of arg to be a bool:

public class MyClass 
{
    public void MyMethod(bool boolArg)
    {
    }
}

The tuple:

("MyProject.MyClass", "(Instance of MyProject.MyClass).MyMethod : stringArg:System.String -> System.Void")

is replaced by:

("MyProject.MyClass", "(Instance of MyProject.MyClass).MyMethod : boolArg:System.Boolean -> System.Void")

Syntactic versioning will detect the lack of the first tuple in the target set and return a Major magnitude change.

synver

Syntactic Versioning comes packaged as a dotnet tool called synver, and can easily be installed with the command:

dotnet tool install -g synver

The tool has the following usage:

USAGE: synver.exe [--help] [--surface-of <path>] [--decompile] [--output <path>] [--diff <source> <target>]
                  [--bump <version> <source> <target>] [--magnitude <source> <target>]

OPTIONS:

    --surface-of <path>   Get the public api surface of the .net binary as lson
    --decompile           Decompile instead of using reflection
    --output <path>       Send output to file
    --diff <source> <target>
                          Get the difference between two .net binaries
    --bump <version> <source> <target>
                          Get the next version based on the difference between two .net binaries
    --magnitude <source> <target>
                          Get the magnitude of the difference between two .net binaries
    --help                display this list of options.

This tool has four main commands, --surface-of, --diff, --bump and --magnitude.

--surface-of

The --surface-of command takes a path and serialises the API into a format called LSON (Lisp inspired serialisation):

synver --surface-of path/to/some.dll

MyProject is represented in (manually formatted) LSON as:

(namespaces ((
    namespace "MyProject" 
    types ((
        EnumValue typ (typ "MyProject.MyClass") netType "Class" members ((
            Method (typ (typ "MyProject.MyClass") instance "Instance" name "MyMethod" 
                result (typ "System.Void") 
                params ((typ (typ "System.String") name "stringArg"))
            )
        ) (
                Constructor (typ (typ "MyProject.MyClass") params ()))
        ) sumtype "False" baseTyp (typ "System.Object")))))
)

Here we can see the class MyClass in the project MyProject which has the instance member MyMethod that takes in a string and returns void and a default constructor.

--diff

The --diff command takes source and target paths and returns the difference in the API in a readable format:

synver --diff path/to/source.dll path/to/target.dll

When applied to the breaking change in MyClass described above this displays:

* MyProject.MyClass

    + (Instance of MyProject.MyClass).MyMethod :
            boolArg:System.Boolean
            -> System.Void

    - (Instance of MyProject.MyClass).MyMethod :
            stringArg:System.String
            -> System.Void

This shows the breaking change in MyClass of the MyMethod(string stringArg) being replaced by the MyMethod(bool boolArg).

--magnitude and --bump

The --magnitude command takes source and target paths and returns the magnitude of the version change.

synver --magnitude path/to/source.dll path/to/target.dll

Major 

The --bump command takes the current version number, source and target paths and returns the new version number, according to the syntax change.

synver --bump 1.2.3 path/to/source.dll path/to/target.dll

2.0.0

Library API as text

The commands can take paths to .dll files as arguments, but it can be tricky to get a previous version of a library .dll. Luckily Syntactic Versioning can also use text files produced by the --surface-of command to determine differences.

The --surface-of and --diff commands can output their results to a file using the --output argument. To write the lson representation of the built MyProject.dll to the file MyProject.lson, synver can be called with:

synver --surface-of path/to/MyProject.dll --output MyProject.lson

With this file in stored in source control, the bump command provides an easy way to find the new version according to the library API change and Semantic versioning rules.

synver --bump 1.2.3 MyProject.lson ./bin/Debug/MyProject.dll

2.0.0

The diff command provides a way to document changes in the library API once the new version is known:

synver --diff MyProject.lson ./bin/Debug/MyProject.dll --output 1.2.3-2.0.0.txt

The next post in this series describes a way to use the synver tool to fully automate the versioning process.