Generating app / mix version directly from git tags

As part of automating my release handling, I’ve been fiddling with ways of pushing the git tag into the version used in mix.exs and wondered if anybody else has come up with a better / nicer way:

defmodule Foo.MixProject do
  use Mix.Project

  def project() do
    [version: set_version(),
     ...]
  end

  # stash the tag so that it's rolled into the next commit and therefore
  # available in hex packages when git tag info may not be present
  # alternatively we *could* abuse hex_metadata.config for that?
  defp set_version() do
    v = get_version()
    File.write!(".version", v)
    v
  end

  defp get_version() do
    # get version from closest git tag, last saved tag, or assume 0.0.0-alpha
    get_version(File.read(".version"), File.dir?(".git"), System.find_executable("git"))
    |> String.replace_prefix("v", "")
    |> String.trim_trailing()
  end

  # fallback when there is no actual error, just missing information
  defp get_version(:missing), do: "0.0.0-alpha"
  # no .version file, must be first run: assume lowest possible version
  defp get_version({:error, _}, _, _), do: get_version(:missing)
  # .version exists, but no .git dir, probably inside hex package
  defp get_version({:ok, v}, false, _), do: v
  # .version exists, and we can read git tags
  defp get_version({:ok, _}, true, git) when is_binary(git) do
    case System.cmd("git", ~w[describe --dirty --abbrev=0 --tags --first-parent],
           stderr_to_stdout: true
         ) do
      {v, 0} -> v
      _ -> get_version(:missing)
    end
  end

  # something is very wrong so we give up and hex publishing will fail
  defp get_version(_, _, _), do: "unknown"
4 Likes

I’ve slightly extended this now,

defmodule Zen.MixProject do
  use Mix.Project

  def project() do
    {tag, description} = git_version()

    [
      app: :zen,
      version: tag,
      description: "zen " <> description,
      escript: escript(),
      elixir: "~> 1.9",
      start_permanent: Mix.env() == :prod,
      build_embedded: Mix.env() == :prod,
      deps: deps()
    ]
  end
...

  defp git_version() do
    # pulls version information from "nearest" git tag or sha hash-ish
    {hashish, 0} =
      System.cmd("git", ~w[describe --dirty --abbrev=7 --tags --always --first-parent])

    full_version = String.trim(hashish)

    tag_version =
      hashish
      |> String.split("-")
      |> List.first()
      |> String.replace_prefix("v", "")
      |> String.trim()

    tag_version =
      case Version.parse(tag_version) do
        :error -> "0.0.0-#{tag_version}"
        _ -> tag_version
      end

    {tag_version, full_version}
  end
...
end
3 Likes

Nice. I really miss it when having to work in Elixir and think mix should have this built in for both applications and releases like rebar3 does.

The only “issue” I know some have pointed out, and may be part of why they don’t want to support it directly, is that when not directly on a tag the versions are not proper “semver”… but now that I check the semver.org page I don’t think that is actually the case.

I remember someone saying that the +build that rebar3 adds wasn’t proper because it was put on the last tag while the correct semver, since the code being built is now beyond the version of the tag, would be a higher version and +buildbeing a prerelease of it.

But according the semver.org the build metadata is not related to prereleases and is to not be taken into account for precedence of versions.

So maybe the versions are fine?

1 Like

This is my understanding of the build annotation as well. I still worry about libs defaulting to lexographic sorting, though.

Yeah IMO this should be built in. I added support for this to erlang.mk years ago.

I have 2 things I wanted to do differently, in the App Spec http://erlang.org/doc/man/app.html the id field is not actually used by OTP, and we could put the full description in there. The only thing stopping me is I can’t find how to get that data at runtime - I need to put in various places the particular version I’m using so we can see them in rabbitmq & http headers for traceability. If I could get that working, I’d leave the semver-ish version alone.

Everybody who has lived in a non-Latin alphabet country has already given up on lexicographic sorting ;-).

Regarding http headers for traceability, this is something I need to figure out soon for https://opentelemetry.io/. I think application/vsn will be good to go in the CorrelationContext I think, https://github.com/open-telemetry/oteps/blob/f70855a4016ca41ce1eae78fed9420c304e2b2ba/text/0042-separate-context-propagation.md#observability-api and as a Resource on the span.

If you are working on similar with rabbitmq please checkout https://github.com/open-telemetry/opentelemetry-erlang and maybe we can talk. There are numerous gitter channels https://github.com/open-telemetry/community#erlangelixir-sdk but I’m also on the erlang and elixir Slacks.

I am using slightly simpler approach:

  defp version do
    case System.cmd(
           "git",
           ~w[describe --dirty=+dirty],
           stderr_to_stdout: true
         ) do
      {version, 0} ->
        version
        |> Version.parse()
        |> bump_version()
        |> to_string()

      _ ->
        "0.0.0-dev"
    end
  end

  defp bump_version(%Version{pre: []} = version), do: version

  defp bump_version(%Version{patch: p} = version),
    do: struct(version, patch: p + 1)

This will provide proper SemVer package (removing v prefix should be easier than in your version) and will also bump version if it isn’t on the tag.

2 Likes

Any idea why this wouldn’t be correctly setting the release version to the git tag?

elixir 1.14.3-otp-25

defp version do
    with {"v" <> version, _} <- System.cmd("git", ~w[describe --tags], stderr_to_stdout: true),
         {:ok, version} <- version |> String.trim_trailing("\n") |> Version.parse() do
      to_string(version)
    else
      _ ->
        "0.1.0"
    end
  end
 def project do
    [
      default_release: :my_app,
      app: :my_app,
      version: version(),
      elixir: "~> 1.12",
      elixirc_paths: elixirc_paths(Mix.env()),
      compilers: [:phoenix] ++ Mix.compilers() ++ [:surface],
      dialyzer: [
        plt_add_apps: [:ex_unit, :mix],
        plt_core_path: "priv/plts",
        plt_local_path: "priv/plts",
        ignore_warnings: ".dialyzer_ignore.exs"
      ],
      start_permanent: Mix.env() == :prod,
      aliases: aliases(),
      deps: deps(),
      preferred_cli_env: [
        "test.feature": :test
      ],
      releases: [
        release_a: [
          runtime_config_path: "config/release_a/releases.exs",
          include_executables_for: [:unix],
          version: version(),
          applications: [
            runtime_tools: :permanent,
            certifi: :permanent,
            castore: :permanent,
            tls_certificate_check: :permanent,
            opentelemetry_exporter: :temporary,
            opentelemetry: :temporary
          ]
        ],

Looks fine to me and works via iex but the release is still returning 0.1.0 when calling System.get_env("RELEASE_VSN") or Application.spec(:my_app, :vsn) in the release:

iex(2)>  with {"v" <> version, _} <- System.cmd("git", ~w[describe --tags], stderr_to_stdout: true),
...(2)>          {:ok, version} <- version |> String.trim_trailing("\n") |> Version.parse() do
...(2)>       to_string(version)
...(2)>     else
...(2)>       _ ->
...(2)>         "0.1.0"
...(2)>     end
"1.1.574"

Is this currently the best way to thread a git tag into an application config/mix config?

I haven’t finalized my requirements yet, but I think I want to build something that fetches branch name and sha as either two config values or one config value with the values concatenated, and then access them at compile time decide how I want to use them for local, staging, and prod builds.

if this is built, then does your CI have access to git + tags to find it?