gnikyt Code ramblings.

Git release branches

A method I like to utilize internally, which I’ve been slowly pushing into my open source work, is to create branches for each major/minor release. Many developers will say branches are meant to be short-lived, to be eventually merged into your main branch. I believe this is true for features and bugfixes, but there is a benefit to keeping branches around for the long term.

Essentially, if you follow symver ([major].[minor].[patch]) for versioning, you would create and maintain a branch for each [major].[minor] release. For example… if I tagged the production branch with v9.0.0, a new major release, I would create a supplementary branch 9.0. If I then released a bugfix, v9.0.1, I would then issue a pull request to merge code for v9.0.1 into branch 9.0 to keep it updated with patches.

$ git branch -l | sort -V
# ...
8.2
8.3
9.0
develop
master

By having your releases tagged and supplementing it with release branches, you will:

This may be overkill for small and personal projects, but when you’re working with a team and potentially have to support multiple versions of a codebase, then this flow is easy to adopt.

Below is a script you can use to walk through your existing repository (assuming everything is tagged in symver format) and create the [major].[minor] branches for you.

#!/usr/bin/env python
#
# Usage: `python release_branches.py`
#   For a "dry run": `DRY_RUN=1 python release_branches.py`
#

import subprocess
import re
from os import environ
from typing import List, Dict


def get_tag_list() -> List[str]:
    """
        Get list of tags, sorted.
    """

    # Ensure tags are in vX.Y.Z format
    mmp = re.compile(r"^v([0-9]+).([0-9]+).([0-9]+)$")

    # Get all tags that match the format
    tags = subprocess.getoutput(" ".join(("git", "tag", "-l"))).split("\n")
    result = list(filter(lambda tag: (mmp.match(tag) is not None), tags))
    result = [tag[1:] for tag in result]
    result.sort(reverse=True)
    return result


def major_minor_list(tags: List[str]) -> Dict[str, str]:
    """
        Create a list of major.minor versions.
    """

    mm = {}
    for tag in tags:
        # Strip off patch from tag
        cleaned_tag = ".".join(tag.split(".")[:-1])
        if cleaned_tag not in mm:
            # Add to dict if major.minor doesn't exist yet
            mm[cleaned_tag] = tag
    return mm


def make_branch(branch: str, tag: str) -> None:
    """
        Make a branch from a tag.
    """

    # Checkout tag, create branch of major.minor based on tag, push to BB
    subprocess.run(["git", "checkout", f"v{tag}"])
    subprocess.run(["git", "checkout", "-b", branch])
    subprocess.run(["git", "push", "origin", branch])


tags = get_tag_list()
mm = major_minor_list(tags)
for branch, tag in mm.items():
    print(f"Making branch '{branch}' based on tag of '{tag}'...")
    if environ.get('DRY_RUN') is None:
        make_branch(branch, tag)
    print(">> Complete")

Running with the DRY_RUN env supplied, you’ll be able to see the branches it will created based on your existing tags before proceeding with the creation.