Setup version increment and automated release process | by Maksym Balatsko | Towards Data Science

I’m keen on process automation and I strongly believe that time and money investments into process automation in the development phase is a primary need, no matter how long the project is going to last. When you invest in processes automation you vastly decrease further maintenance expenses.
I think one of the topics, which is not properly covered in ML articles is release process automation and project version increment. I don’t want to discuss possible release options in this post, it is not about it. Here we will discuss how to set up the release process to your CI/CD tool (Bitbucket pipelines, Gitlab pipelines, etc.) I think it is safe to say that in most cases release is a file or set of files, which will be somehow integrated into client existing solution (no matter if it is a jar file with the spring boot app inside, set of python scripts which are used for model training and predictions or a docker image in which core application runs). Problem statement: We want to manage product versions and we want to be able to return to the concrete version to be able to test and diagnose problems that happened on the production.
To begin with, let’s define what does number in version string means.
Definition from Semantic versioning (https://semver.org/)
Given a version number MAJOR.MINOR.PATCH-pre_release-label, increment the:
MAJOR version when you make incompatible API changesMINOR version when you add functionality in a backward-compatible mannerPATCH version when you make backward-compatible bug fixes.
MAJOR version when you make incompatible API changes
MINOR version when you add functionality in a backward-compatible manner
PATCH version when you make backward-compatible bug fixes.
Pre-release-label is the placeholder of the version, which is kept before release.
This is a best-practice definition, but of course, you can choose your own versioning scheme. So according to this definition, we need to define three tasks:
Release and increment the major versionRelease and increment the minor versionRelease and increment the patch version
Release and increment the major version
Release and increment the minor version
Release and increment the patch version
The next step is to define how the release would be represented in GIT history except for the git tag. There are several techniques to achieve this, the most popular are separate release branch or detached commit. In this post, we will be discussing detached commit because it could much easier be automated (no merge needed).
Let’ begin with the theory. Detached commit is a commit that doesn't belong to any branch and can be pointed only directly. How to make it searchable in the git history without knowing the commit hash? You have just to assign a git tag to it. Then you can filter git history by tags in any VCS UI.
Somehow you have to keep actual version string. The easiest way is to keep it in a text file in the root of the repository. The policies of VERSION file:
File contents can’t be manually changedIt can’t be changed in a feature-branch (the best practice is to create a pipeline step, which compares it against master)File changes only after release and automatically committed directly to master branch.
File contents can’t be manually changed
It can’t be changed in a feature-branch (the best practice is to create a pipeline step, which compares it against master)
File changes only after release and automatically committed directly to master branch.
To your CI/CD pipelines file, you have to add three custom/manual steps/jobs, which would perform release and an appropriate increment. And optionally add step/job which runs only on feature branches and which checks if VERSION file was changed against master.
Bash script to check if VERSION file changed against master:
git diff --exit-code VERSION
And now the most interesting part:
We have to create a script which manages VERSION file with functions: get version, get version with placeholder, increment version major/minor/patchWe have to define release branch restriction policy (release could be made only on master branch) and create detached commit push flow.Define CI/CD release step (Here we will define it for Bitbucket pipelines and Gitlab pipelines)
We have to create a script which manages VERSION file with functions: get version, get version with placeholder, increment version major/minor/patch
We have to define release branch restriction policy (release could be made only on master branch) and create detached commit push flow.
Define CI/CD release step (Here we will define it for Bitbucket pipelines and Gitlab pipelines)
I hate writing bash scripts. I use bash only to define high-level steps and run python scripts from it, because I strongly believe that bash scripts have ugly notation, are not readable for less experienced Linux users and are not maintainable in the long term perspective. fire library can help you easily write good CLI-runnable python scripts. Check out my previous post:
towardsdatascience.com
VERSION file management script (nothing interesting to discuss, just file writing operations):
#!/usr/bin/env pythonimport osimport reimport firepre_release_placeholder = 'SNAPSHOT'version_filepath = os.path.join('.', 'VERSION')version_pattern = re.compile(fr'^\d+.\d+.\d+(-{pre_release_placeholder})?$')def get(with_pre_release_placeholder: bool = False):    with open(version_filepath, 'r') as version_file:        version_lines = version_file.readlines()        assert len(version_lines) == 1, 'Version file is malformed'        version = version_lines[0]        assert version_pattern.match(version), 'Version string is malformed'        if with_pre_release_placeholder:            return version        else:            return version.replace(f'-{pre_release_placeholder}', '')def write_version_file(major: int, minor: int, patch: int):    version = f'{major}.{minor}.{patch}-{pre_release_placeholder}'    with open(version_filepath, 'w') as version_file:        version_file.write(version)def inc_patch():    version = get()    major, minor, patch = version.split('.')    write_version_file(major, minor, int(patch) + 1)def inc_minor():    version = get()    major, minor, patch = version.split('.')    write_version_file(major, int(minor) + 1, patch)def inc_major():    version = get()    major, minor, patch = version.split('.')    write_version_file(int(major) + 1, minor, patch)if __name__ == "__main__":    fire.Fire({        'get': get,        'inc-patch': inc_patch,        'inc-minor': inc_minor,        'inc-major': inc_major    })
Now let’s develop the release script. Firstly we have to check whether the release script is running on the right branch:
# Bitbucketcommit=${BITBUCKET_COMMIT:-$(git rev-parse HEAD)}# Gitlabcommit=${CI_COMMIT_SHA:-$(git rev-parse HEAD)}# Define ALLOWED_RELEASE_BRANCH in VCS pipelines secret variables.branch=${ALLOWED_RELEASE_BRANCH:-master}if ! git branch -a --contains "${commit}" | grep -e "^[* ]*remotes/origin/${branch}\$"then  echo -e "###\n### Not on ${branch}. Only ${branch} commits can be released.\n###"  exit 1else  echo -e "###\n### Releasing of ${commit} on ${branch}\n###"fi
Then you have to write some release stuff such as: publish docker image or copy to S3 bucket or whatever you need. Don’t forget to do it with the right VERSION file:
version=$(./scripts/version.py get)version_file=VERSIONecho ${version} > ${version_file}
Then we have to push a detached commit with modified in a previous step VERSION file:
# Define some cool release pusher to ditinguish from amnual commits :)git config user.name "Elon Musk"git config user.email "elon.musk@spacex.com"echo "Pushing detached tag of new version"git add ${version_file}git commit -m "Release version ${version}"git tag  -a ${version} -m "Release version ${version} tag"git push origin ${version}
Fetch back to master branch and increment version with a pre-release placeholder:
echo "Pushing new version to ${branch}"git fetch origin "${branch}:${branch}" || git pullgit checkout "${branch}"# Parameter of the scriptrelease_type=${1}./scripts/version.py inc-${release_type}next_working_version=$(./scripts/version.py get --with-pre-release-placeholder)git add ${version_file}git commit -m "Incrementing working version to ${next_working_version} after ${version} release."git push origin ${branch}
And that's it. We have created a clean release script. Now let’s develop Bitbucket and Gitlab pipeline steps snippets.
Bitbucket:
image:  name: python:3.7.6-slim-busterpipelines:  default:    - step:        - name: Test version file not changed        - script:            - git diff --exit-code VERSION  custom:    release-inc-patch:      - step:          caches:            - pip          name: Release current version and increment patch version          script:            - apt-get update && apt-get install -y git            - pip install fire==0.2.1            - ./scripts/release.sh patch    release-inc-minor:      - step:          caches:            - pip          name: Release current version and increment minor version          script:            - apt-get update && apt-get install -y git            - pip install fire==0.2.1            - ./scripts/release.sh minor    release-inc-major:      - step:          caches:            - pip          name: Release current version and increment major version          script:            - apt-get update && apt-get install -y git            - pip install fire==0.2.1            - ./scripts/release.sh major
Running custom pipeline steps: https://confluence.atlassian.com/bitbucket/run-pipelines-manually-861242583.html
Gitlab:
test-version-changed:  stage: test  name: Test version file not changed  script:    - git diff --exit-code VERSIONrelease-inc-patch:  stage: deploy  image: python:3.7.6-slim-buster  caches:    - pip  name: Release current version and increment patch version  script:    - apt-get update && apt-get install -y git    - pip install fire==0.2.1    - ./scripts/release.sh patch  when: manual  only:    - masterrelease-inc-minor:  stage: deploy  image: python:3.7.6-slim-buster  caches:    - pip  name: Release current version and increment minor version  script:    - apt-get update && apt-get install -y git    - pip install fire==0.2.1    - ./scripts/release.sh minor  when: manual  only:    - masterrelease-inc-major:  stage: deploy  image: python:3.7.6-slim-buster  caches:    - pip  name: Release current version and increment major version  script:    - apt-get update && apt-get install -y git    - pip install fire==0.2.1    - ./scripts/release.sh major  when: manual  only:    - master
Run manual jobs: https://forum.gitlab.com/t/gitlab-ci-run-pipeline-manually/13797
That’s it! All sources you can find on my GitHub: https://github.com/mbalatsko/release-version-increment
I hope this article will be useful and informative. I’m looking forward to your feedback!