Illustration showing GitHub CI processes with versions

Adding versions to your GitHub Actions

Matthew Casperson

Illustration showing GitHub CI processes with versions

GitHub Actions are slowly rolling out to users as a beta. This new feature gives GitHub users a way to execute builds and deployments directly from their code using infrastructure managed by GitHub. This provides a lot of opportunities for developers, but while Actions are incredibly powerful and flexible, I immediately ran into the issue of versioning my builds.

Typically, a CI system will include some kind of incrementing counter that can be used as the patch release for any build. This means each build is automatically assigned a new number, and any resulting artifacts inherit a meaningful version. Unfortunately, in the current beta of GitHub Actions, there is no equivalent to a build number. The build environment exposes information like Git SHAs, repositories, users, and events but no build number.

This is inconvenient, to say the least, but there is a solution.

Implementing GitVersion

GitVersion is a tool that generates SemVer version numbers based on the tags in a Git repository. GitVersions is ideal for use with GitHub Actions because the Git repository itself is the source of truth for versioning, and no special tools beyond the Git client are required to manage the version numbers.

GitVersion also supplies a Docker image, which can be directly used by GitHub Actions.

This means that, in theory, we have everything we need to generate meaningful version numbers as part of the GitHub Actions Workflow. In practice though, we still have some hoops to jump through.

GitHub Actions and shared variables

GitHub Actions is based on the idea of individual jobs. These jobs can run on the underlying VM, or in a Docker container. Composing builds in this way offers great flexibility, but on the downside, it’s difficult to capture the output of one job and use it in another. CI servers often work around this problem by capturing the output with special markers. For example, TeamCity can watch for output in the format ##teamcity[setParameter name='env.whatever' value='myvalue'] and create a variable in response. Unfortunately, the beta of GitHub Actions does not provide any support for passing variables.

What we do have shared between jobs is the filesystem. Jobs that run directly on the VM can access the path /home/runner/work/RepoName/RepoName/ (where RepoName is replaced with the name of the GitHub repository). Docker jobs have that same path mounted to /github/workspace.

We can use this shared file system to save the version generated by GitVersion, and then consume it in later steps.

Example build

To see how this works in practice, take a look at the Workflow definition below. This is an example from a project that builds a Python AWS Lambda application:

name: Python package

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - name: Get Git Version
      uses: docker://gittools/gitversion:5.0.2-beta1-27-linux-centos-7-netcoreapp2.2
      with:
        args: /github/workspace /nofetch /exec /bin/sh /execargs "-c \"echo $GitVersion_FullSemVer > /github/workspace/version.txt\""
    - name: Set up Python 3.7
      uses: actions/setup-python@v1
      with:
        python-version: 3.7
    - name: Package dependencies
      run: |
        python -m pip install --upgrade pip
        cd hello_world
        pip download -r requirements.txt
        unzip \*.whl
        rm *.whl
        ls -la
    - name: Extract Octopus Tools
      run: |
        mkdir /opt/octo
        cd /opt/octo
        wget -O /opt/octo/octopus.zip https://download.octopusdeploy.com/octopus-tools/6.12.0/OctopusTools.6.12.0.portable.zip
        unzip /opt/octo/octopus.zip
        chmod +x /opt/octo/Octo
    - name: Pack Application
      run: >-
        /opt/octo/Octo pack .
        --outFolder /home/runner/work/AWSSamExample/AWSSamExample
        --basePath /home/runner/work/AWSSamExample/AWSSamExample/hello_world
        --id AwsSamLambda
        --version $(cat /home/runner/work/AWSSamExample/AWSSamExample/version.txt)
        --format zip
    - name: Push to Octopus
      run: >-
        /opt/octo/Octo push
        --server ${{ secrets.MATTC_URL }}
        --apiKey ${{ secrets.MATTC_API_KEY }}
        --package /home/runner/work/AWSSamExample/AWSSamExample/AwsSamLambda.$(cat /home/runner/work/AWSSamExample/AWSSamExample/version.txt).zip
        --overwrite-mode IgnoreIfExists

The first interesting part of this workflow is where we call the GitVersion docker image.

The trick here is to call GitVersion in a way that will save the resulting SemVer version to a file instead of printing it to the console output. We do this by setting the /exec argument to /bin/sh, and setting the /execargs argument to "-c \"echo $GitVersion_FullSemVer > /github/workspace/version.txt\"". These options result in GitVersion executing the shell, which writes the value of the environment variable GitVersion_FullSemVer (defined for us by GitVersion) to the file /github/workspace/version.txt.

The end result of this job is a file called /github/workspace/version.txt or /home/runner/work/RepoName/RepoName/version.txt, depending on whether or not the job is running inside a Docker container.

The environment variable $GitVersion_FullSemVer is just one of many provided by GitVersion. Any of the fields in the JSON below can be prefixed with GitVersion_ and read as an environment variable.

{                                                           
  "Major":0,
  "Minor":1,
  "Patch":0,
  "PreReleaseTag":"",
  "PreReleaseTagWithDash":"",
  "PreReleaseLabel":"",
  "PreReleaseNumber":"",
  "WeightedPreReleaseNumber":"",
  "BuildMetaData":55,
  "BuildMetaDataPadded":"0055",
  "FullBuildMetaData":"55.Branch.master.Sha.3903750b2aa5d84fd6004b2244cdb491f45520d9",
  "MajorMinorPatch":"0.1.0",
  "SemVer":"0.1.0",
  "LegacySemVer":"0.1.0",
  "LegacySemVerPadded":"0.1.0",
  "AssemblySemVer":"0.1.0.0",
  "AssemblySemFileVer":"0.1.0.0",
  "FullSemVer":"0.1.0+55",
  "InformationalVersion":"0.1.0+55.Branch.master.Sha.3903750b2aa5d84fd6004b2244cdb491f45520d9",
  "BranchName":"master",
  "Sha":"3903750b2aa5d84fd6004b2244cdb491f45520d9",
  "ShortSha":3903750,
  "NuGetVersionV2":"0.1.0",
  "NuGetVersion":"0.1.0",
  "NuGetPreReleaseTagV2":"",
  "NuGetPreReleaseTag":"",
  "VersionSourceSha":"0f692a38449b853d8a04aa891ac48e63ebec1add",
  "CommitsSinceVersionSource":55,
  "CommitsSinceVersionSourcePadded":"0055",
  "CommitDate":"2019-08-21"
}
- name: Get Git Version
  uses: docker://mcasperson/gitversion:5.0.2-linux-centos-7-netcoreapp2.2
  with:
    args: /github/workspace /nofetch /exec /bin/sh /execargs "-c \"echo $GitVersion_FullSemVer > /github/workspace/version.txt\""

To consume the version, we will use the Octopus CLI tools. In the following job, we download and extract the CLI package so it can be used in subsequent steps.

The Octopus CLI tools are also available as a Docker image, and so we could use these tools from the workflow with uses: docker://octopusdeploy/octo:6.12.0. However, calling docker images directly makes it difficult to use shell expansions, which we will need to extract the content of the file version.txt and pass it as a command-line argument. This is why we extract the tool locally instead:

- name: Extract Octopus Tools
  run: |
    mkdir /opt/octo
    cd /opt/octo
    wget -O /opt/octo/octopus.zip https://download.octopusdeploy.com/octopus-tools/6.12.0/OctopusTools.6.12.0.portable.zip
    unzip /opt/octo/octopus.zip
    chmod +x /opt/octo/Octo

After the Octopus CLI tools are extracted, we can call them to package the application. Note how we use shell expansion with the argument --version $(cat /home/runner/work/AWSSamExample/AWSSamExample/version.txt) to read the value of the version.txt file created by GitVersion (AWSSamExample is the name of my GitHub repo). This is how we pass variables between jobs:

- name: Pack Application
  run: >-
    /opt/octo/Octo pack .
    --outFolder /home/runner/work/AWSSamExample/AWSSamExample
    --basePath /home/runner/work/AWSSamExample/AWSSamExample/hello_world
    --id AwsSamLambda
    --version $(cat /home/runner/work/AWSSamExample/AWSSamExample/version.txt)
    --format zip

We use the Octopus CLI in a similar way to push the resulting application to the Octopus Server:

- name: Push to Octopus
  run: >-
    /opt/octo/Octo push
    --server ${{ secrets.MATTC_URL }}
    --apiKey ${{ secrets.MATTC_API_KEY }}
    --package /home/runner/work/AWSSamExample/AWSSamExample/AwsSamLambda.$(cat /home/runner/work/AWSSamExample/AWSSamExample/version.txt).zip
    --overwrite-mode IgnoreIfExists

Conclusion

GitHub Actions are a powerful new feature for developers, but there are some rough edges in the beta that you’ll have to work around for production scenarios. GitVersion provides a neat solution to the lack of build numbers and allows GitHub actions to interact with platforms like Octopus.

Loading...