Numer wersji z Mercurial revision hash

31.05.2011

Dzisiaj trochę kodu źródłowego z mojego ostatniego projektu. Przy okazji szlifowania projektu i tworzenia AboutWindow pomyślałem, że fajnie byłoby mieć gotowy mechanizm do szybkiego, łatwego i jednoznacznego numerowania wersji projektu i wiązania tej wersji z rewizją w systemie kontroli wersji (Mercurial). Prawidłowe podpisywanie projektu numerem wersji, który pozwala w prosty sposób zidentyfikować, z której wersji kodu (rewizji) została skompilowana dana wersja projektu jest nie do przecenienia. .NET Framework i Visual Studio udostępniają mechanizm dekorowania assembly atrybutem AssemblyVersion. Przykład:

[AssemblyVersion(x.y.b.r)]

gdzie litery oznaczają odpowiednio: numer dużej wersji, numer małej wersji, numer build’a, numer rewizji. Dopuszczalne jest użycie znaku gwiazdki (*) zamiast cyfr w celu automatycznego wygenerowania odpowiednich członów tekstu wersji. Można napisać 1.0.* i zostawić generowanie numeru wersji kompilatorowi. Jednak nie wyjdzie z tego raczej nic dobrego. Kompilator w miejsce numeru build’a wstawi domyślnie ilość dni, jakie upłynęły od 01.01.2000, zaś w miejsce rewizji ilość sekund od północy danego dnia, w którym kompilowany był projekt. Ciężko taki numer powiązać z numerem rewizji w repozytorium kodu. W przypadku gdy korzystamy z SVN, problem da się w miarę elegancko ogarnąć dzięki skryptom MSBuild’a i faktowi, że SVN ma globalną numerację rewizji (rewizja 45 u każdego na komputerze będzie wyglądać tak samo). Jeżeli używasz SVN’a i masz problem z generowaniem numeru wersji projektu to polecam artykuł Daniela Dąbrowskiego.

Jeżeli jesteś dumnym użytkownikiem DVCS’a (w moim przypadku Mercuriala na BitBucket), to masz mały problem. W Mercurialu numer rewizji znaczy tyle co nic i może być inny na każdej maszynie, na której będziesz budował swój kod. Do jednoznacznego identyfikowania rewizji kodu używa się hash’a w postaci 12 znakowego ciągu. Tego ciągu znaków nie da się w żaden sposób przetransformować monotoniczny ciąg liczbowy 1,2,3…, co pozwoliłoby na wykorzystanie go bezpośrednio w wyżej wspomnianym atrybucie AssemblyVersion. Jedynym sensownym rozwiązaniem wydaje się być zapamiętywanie hasha rewizji w innym atrybucie w celu późniejszego wyłuskania go i odpowiedniego przetransformowania we wspomnianym wcześniej oknie About. Wymaga to jednak pogrzebania się w skryptach MSBuild’a co nie jest zbyt wdzięcznym zajęciem.

Żeby zautomatyzować cały proces generowania zawartości atrybutów, podobnie jak Daniel Dąbrowski, skorzystałem z MSBuild Community Tasks. Dzięki temu będę mógł szybko wygenerować zawartość pliku AssemblyInfo.cs. Jeżeli chodzi o atrybut AssemblyVersion to nie ma problemu z wpisaniem na sztywno wartości np 1.1. Problem pojawia się z AssemblyDescription, w którym przechowuję hash rewizji. Skąd wziąć hash? Trzeba napisać własny Task MSBuild’a, który pobierze numer hash i umieści go w zmiennej, którą będzie można wykorzystać w dalszej części skryptu MSBuild’a. Kod tasku:

public class GetHgHash : Task
{
    [Output]
    public string Hash
    {
        get;
        set;
    }

    public override bool Execute()
    {
        var process = new Process();
        process.StartInfo.UseShellExecute = false;
        process.StartInfo.RedirectStandardOutput = true;
        process.StartInfo.FileName = "hg.exe";
        process.StartInfo.Arguments = "tip";
        process.Start();
        var output = process.StandardOutput.ReadToEnd();
        process.WaitForExit();
        var result = ParseOutput(output);

        if (result)
        {
            Log.LogMessage(string.Format("Retrieved Hg hash: {0}", Hash));
        }
        else
        {
            Log.LogWarning("Unable to retrive Hg hash. Probably project is not under Mercurial version control system.");
        }

        return result;
    }

    private bool ParseOutput(string output)
    {
        var line = output.Split('\n').FirstOrDefault();
        if (line == null)
        {
            return false;
        }

        var match = Regex.Match(line, @"^changeset:.+?[\-0-9]+:(.+?)$");
        Hash = match.Success ? match.Groups[1].Value : string.Empty;
        return match.Success;
    }
}

Kolejny krok to wywołanie Tasku w skrypcie MSBuild. W /tools/msbuild znajdują się MSBuild Community Task zaś w katalogu tools/hghash znajduje się Mój Task oraz skrypt HgHash.Targets:

<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <UsingTask TaskName="GetHgHash" AssemblyFile="HgHash.dll"></UsingTask> 
</Project>

W katalogu głównym solution znajduje się skrypt Common.Targets:

<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="tools\hghash\HgHash.Targets"/>
  <Import Project="tools\msbuild\MSBuild.Community.Tasks.Targets"/>

  <PropertyGroup>
    <HgHash></HgHash>
    <AssemblyInfoFile>Properties\AssemblyInfo.cs</AssemblyInfoFile>
    <AssemblyTitle></AssemblyTitle>
    <AssemblyProduct></AssemblyProduct>
    <AssemblyCompany></AssemblyCompany>
    <AssemblyVersion></AssemblyVersion>
    <AssemblyCopyright></AssemblyCopyright>
    <ComVisible></ComVisible>
    <CLSCompliant></CLSCompliant>
    <Guid></Guid>
  </PropertyGroup>


  <Target Name="GetHgHash">
    <GetHgHash>
      <Output TaskParameter="Hash" PropertyName="HgHash" />
    </GetHgHash>
  </Target>

  <Target Name="ReadVersionInfo">
    <XmlRead XPath="/AssemblyInfo/AssemblyTitle" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="AssemblyTitle" />
    </XmlRead>
    <XmlRead XPath="/AssemblyInfo/AssemblyCompany" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="AssemblyCompany" />
    </XmlRead>
    <XmlRead XPath="/AssemblyInfo/AssemblyProduct" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="AssemblyProduct" />
    </XmlRead>
    <XmlRead XPath="/AssemblyInfo/AssemblyCopyright" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="AssemblyCopyright" />
    </XmlRead>
    <XmlRead XPath="/AssemblyInfo/ComVisible" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="ComVisible" />
    </XmlRead>
    <XmlRead XPath="/AssemblyInfo/CLSCompliant" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="CLSCompliant" />
    </XmlRead>
    <XmlRead XPath="/AssemblyInfo/Guid" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="Guid" />
    </XmlRead>
    <XmlRead XPath="/AssemblyInfo/AssemblyVersion" XmlFileName="Version.xml">
      <Output TaskParameter="Value" PropertyName="AssemblyVersion" />
    </XmlRead>
  </Target>

  <Target Name="GenerateAssemblyInfo" DependsOnTargets="GetHgHash;ReadVersionInfo">

    <AssemblyInfo CodeLanguage="CS"
      OutputFile="$(AssemblyInfoFile)"
      AssemblyTitle="$(AssemblyTitle)"
      AssemblyDescription="$(HgHash)"
      AssemblyCompany="$(AssemblyCompany)"
      AssemblyProduct="$(AssemblyProduct)"
      AssemblyCopyright="$(AssemblyCopyright)"
      ComVisible="$(ComVisible)"
      CLSCompliant="$(CLSCompliant)"
      AssemblyVersion="$(AssemblyVersion)" 
                  Guid="$(Guid)"/>
  </Target>

  <Target Name="GeneratedAndCompileAssemblyInfo" DependsOnTargets="GenerateAssemblyInfo">
    <CreateItem Include="$(AssemblyInfoFile)">
      <Output ItemName="Compile" TaskParameter="Include"/>
    </CreateItem>
    <Touch Files="$(AssemblyInfoFile)" Time="1900-01-01" />
  </Target>

</Project>

Kilka słów wyjaśnienia. Dziwić może Target ReadVersionInfo, który czyta informacje o numerze wersji, nazwie projektu itd. z pliku Version.xml dołączonego do każdego projektu. Dlaczego nie umieścić tych informacji bezpośrednio w pliku Common.Targets? Visual Studio cacheuje skrypy projektów .csproj z których będę wywoływał Common.Targets (importował), a co za tym idzie cacheuje też plik Common.Targets (dodany do solution jako solution item). Wszelkie zmiany w pliku będą wymagały przeładowania całego solution. Z tego właśnie powodu zdecydowałem się przenieść te informacje do zewnętrznego pliku XML, który mogę swobodnie modyfikować bez konieczności przeładowywania solution. Plik Version.xml:

<AssemblyInfo>
  <AssemblyTitle>title</AssemblyTitle>
  <AssemblyCompany>company</AssemblyCompany>
  <AssemblyProduct>program name</AssemblyProduct>
  <AssemblyCopyright>Copyright © 2011</AssemblyCopyright>
  <ComVisible>false</ComVisible>
  <CLSCompliant>true</CLSCompliant>
  <Guid></Guid>
  <AssemblyVersion>1.0.1</AssemblyVersion>
</AssemblyInfo>

Kolejnym krokiem jest wywołanie targetu GeneratedAndCompileAssemblyInfo z Common.Targets podczas kompilacji każdego projektu. Można to uzyskać dzięki targetowi BeforeBuild, który znajduje się w każdym pliku projektu .csproj w solution. Należy odkomentować sekcję na końcu pliku .csproj:

<Target Name="BeforeBuild">
</Target>

i umieścić w niej coś takiego:

<Import Project="..\..\..\Common.Targets" />
<Target Name="BeforeBuild">
  <CallTarget Targets="GeneratedAndCompileAssemblyInfo" />
</Target>

Tak wygląda wygenerowany plik AssemblyInfo.cs:

using System;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

[assembly: AssemblyTitle("UI")]
[assembly: AssemblyDescription("788cdedff592")]
[assembly: AssemblyCompany("Krzak")]
[assembly: AssemblyProduct("Produkt")]
[assembly: AssemblyCopyright("Copyright © 2011")]
[assembly: ComVisible(false)]
[assembly: CLSCompliant(true)]
[assembly: AssemblyVersion("1.0.1")]

Gotowe.

Jako bonus kod do wyciągania AssemblyVersion i AssemblyDescription dla wszystkich projektów (assembly) w solution:

public static class Utils
{
    public static string GetAssembliesDetails()
    {
        return AppDomain.CurrentDomain
            .GetAssemblies().Where(x => x.GetCustomAttributes(typeof(MyAssemblyAttribute), false).Any())
            .Aggregate(new StringBuilder(), ProcessAssembly, builder => builder.ToString());
    }

    private static StringBuilder ProcessAssembly(StringBuilder builder, Assembly assembly)
    {
        var descAttr = assembly.GetCustomAttributes(typeof(AssemblyDescriptionAttribute), false).FirstOrDefault();
        if (descAttr == null)
        {
            return builder;
        }

        var description = ((AssemblyDescriptionAttribute)descAttr).Description;
        var version = GetVersion(assembly);
        var fileName = Path.GetFileName(assembly.Location);
        var s = string.Format("{0} - {1} [{2}]", fileName, version, description);
        builder.AppendLine(s);
        return builder;
    }

    private static string GetVersion(Assembly assembly)
    {
        var version = assembly.GetName().Version;
        return string.Format("{0}.{1}.{2}", version.Major, version.Minor, version.Build);
    }
}

Aby odróżnić własne assembly od obcych udekorowałem je prostym atrybutem MyAssemblyAttribute.

[AttributeUsage(AttributeTargets.Assembly)]
public class MyAssemblyAttribute : Attribute
{     
}

Dzięki temu zabiegowi podczas procesu refleksji odczytałem atrybuty AssemblyDescription z hashem oraz AssemblyVersion z moich projektów zamiast z obcych plików dołączonych do solution. Należy też pamiętać o usunięciu oryginalnego pliku AssemblyInfo.cs z projektu zaraz po jego stworzeniu (np. przez Exclude from project). Jeżeli tego nie zrobimy Visual Studio zgłosi błąd kompilacji:

Error 1 The item "Properties\AssemblyInfo.cs" was specified more than once in the "Sources" parameter. Duplicate items are not supported by the "Sources" parameter. “

Przykładowy projekt do ściągnięcia tutaj.

Fin.