Manage multiple versions with GitFlow and GitHub

Imagine this: you want to add a new feature to a repository, but it involves a change that will break backward compatibility. You could release a new major version, but other repositories may depend on the previous version of your software.

In this case, you need to support an older version simultaneously with the next major version. Git-flow is a branching strategy that eases the burden of managing multiple versions, but it can be tricky to implement in a team using GitHub. Git-flow provides several benefits:

These benefits can be achieved in a GitHub setting, but it requires some manual automation with GitHub actions.

"feature/feature_name" into "develop"

All new features should be created from the "develop" branch. No GitHub actions need to run on pull requests created for feature branches into the develop branch, but you may choose to enforce labels on your features. Making sure the feature in review has the appropriate semantic versioning label will be helpful for when we create "release" branches later.

When a pull request is finished for a feature branch, the PR can be squashed and merged into the "develop" branch. Squashing should not occur for release or hotfix PRs for reasons that will be mentioned.

Draft a release on "release/*" push

By far the best benefit of using git-flow with GitHub is that releases can be properly reviewed in the form of a pull request. A GitHub action could be created that drafts a release when a push is made to a "release/*" branch.

on:
  push:
    branches:
      - release/**
      - hotfix/**

permissions:
  contents: read

jobs:
  update_release_draft:
    permissions: write-all
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@master
        with:
          fetch-depth: 0

      # NEW RELEASES
      # Check if this is a release branch
      - name: Check For release branch
        id: is_release_branch
        continue-on-error: true
        run: |
          FILTEREDBRANCHNAME=$(git branch | grep "\* release")
          echo "filtered_branch_name=$FILTEREDBRANCHNAME" >> $GITHUB_OUTPUT

      # Get the release tag
      - name: Get release Tag
        id: get_release_tag
        if: steps.is_release_branch.outputs.filtered_branch_name != ''
        run: |
          RELEASETAG=$(git branch | grep \* | sed -re "s/(\* )?release\///;s/(\w+)\/.*/\1/g")
          echo "release_tag=$RELEASETAG" >> $GITHUB_OUTPUT

      # Draft Release with release branch
      - name: Draft Release with release branch
        id: update_release_draft_with_release_branch
        if: steps.is_release_branch.outputs.filtered_branch_name != ''
        uses: tiller1010/release-drafter@master
        with:
          tag: ${{ steps.get_release_tag.outputs.release_tag }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Publish the draft release when "release/*" PRs are merged

Before merging a release PR, the changes should be reviewed again by another developer. If any additional commits are made, these should not be squashed before merging. This way, the other branches are aware of the additional commits, and merges can occur more smoothly, bringing the fixes along too.

When a release branch is merged into "main" the release that was drafted for that pull request can be published. Doing so creates a tag. After the corresponding release is published, a changelog PR is automatically created, approved, and merged. Them, the "release/*" branch can be automatically deleted by a GitHub action.

on:
  pull_request:
    types:
      - closed
    branches:
      - main
      - support/*

jobs:
  publish_release:
    if: ${{ github.event.pull_request.merged }}
    runs-on: ubuntu-latest
    steps:

      - name: Checkout
        uses: actions/checkout@master
        with:
          fetch-depth: 0

      - name: Get repository name
        id: repo-name
        uses: tiller1010/get-repo-name-action@master
        with:
          with-owner: 'true'

      - name: Get release id
        id: get_release_id
        run: |
          TOKEN="${{ secrets.GITHUB_TOKEN }}"
          REPO="${{ steps.repo-name.outputs.repository-name }}"
          RELEASEID=$(curl -H "Accept: application/vnd.github+json" -H "Authorization: token $TOKEN" https://api.github.com/repos/$REPO/releases)
          RELEASEID=$(echo "$RELEASEID" | grep \"id\"  | head -n 1 | sed -re "s/[a-z]*//g;s/[-|,|:|'\"]//g;s/\s//g")
          echo "release_id=$RELEASEID" >> $GITHUB_OUTPUT
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Payload info
        id: payload_info
        uses: tiller1010/payload-info-action@master
        continue-on-error: true

      # START HOTFIX RELEASE RE-DRAFT
      # END HOTFIX RELEASE RE-DRAFT

      - name: Publish release
        uses: eregon/publish-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          release_id: ${{ steps.get_release_id.outputs.release_id }}


      # START CHANGELOG PORTION
      - name: Check if releasing for a support branch
        id: is_support_branch
        continue-on-error: true
        run: |
          FILTEREDBRANCHNAME=$(git branch | grep "\* support")
          echo "filtered_branch_name=$FILTEREDBRANCHNAME" >> $GITHUB_OUTPUT

      - name: Generate Changelog
        id: changelog
        if: steps.is_support_branch.outputs.filtered_branch_name == ''
        uses: tiller1010/tag-changelog@main
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          exclude_types: other,doc,chore
          config_file: .github/tag-changelog-config.js

      - name: Output Changelog
        id: output_changelog
        if: steps.is_support_branch.outputs.filtered_branch_name == ''
        run: TAGCONTENT="${{ steps.changelog.outputs.changelog }}";CHANGELOG=$(cat CHANGELOG.md);CHANGELOG=$(echo "$CHANGELOG" | sed -e "s/# Changelog//");echo -e "# Changelog\n\n$TAGCONTENT$CHANGELOG" > CHANGELOG.md

      - name: Create Pull Request
        id: create_pr
        if: steps.is_support_branch.outputs.filtered_branch_name == ''
        uses: peter-evans/create-pull-request@v4
        with:
          title: Auto-Update CHANGELOG.md
          commit-message: Updated CHANGELOG.md
          labels: automerge

      - name: Approve PR
        if: steps.is_support_branch.outputs.filtered_branch_name == ''
        uses: hmarr/auto-approve-action@v3
        with:
          review-message: Auto approved automated PR
          pull-request-number: ${{ steps.create_pr.outputs.pull-request-number }}
          github-token: ${{ secrets.SOME_USERS_PAT }}

      - name: Auto merge
        if: steps.is_support_branch.outputs.filtered_branch_name == ''
        uses: pascalgn/automerge-action@main
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PULL_REQUEST: ${{ steps.create_pr.outputs.pull-request-number }}
          MERGE_DELETE_BRANCH: true

      - name: Post Changelog Checkout
        if: steps.is_support_branch.outputs.filtered_branch_name == ''
        uses: actions/checkout@master
        with:
          ref: main
      # END CHANGELOG PORTION

      # START MERGE INTO DEVELOP
      # END MERGE INTO DEVELOP

      # Remove the release or hotfix branch after publishing
      - name: Remove PR branch
        uses: dawidd6/action-delete-branch@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          branches: ${{ steps.payload_info.outputs.branch }}

Merge main into develop

After a release branch is merged into the main branch, it should be merged back into the "develop" branch.

...
      # START MERGE INTO DEVELOP
      # Merge support changes into develop so they can be included in the next release
      - name: Merge support -> develop
        if: steps.is_support_branch.outputs.filtered_branch_name != ''
        uses: devmasx/merge-branch@master
        with:
          type: now
          target_branch: develop
          github_token: ${{ secrets.GITHUB_TOKEN }}
          message: Merged support into develop

      # Merge main changes into develop
      - name: Merge main -> develop
        if: steps.is_support_branch.outputs.filtered_branch_name == ''
        uses: devmasx/merge-branch@master
        with:
          type: now
          from_branch: main
          target_branch: develop
          github_token: ${{ secrets.GITHUB_TOKEN }}
          message: Merged main into develop
      # END MERGE INTO DEVELOP
...

Re-Draft releases on "hotfix/*" push

When you must support a previous minor or major version, you should start by checking out that tag. Make sure to checkout the latest patch for that minor or major version. Then, you should create a "support/*.x" branch.

Support branches should function similarly to the "main" branch. Tags will be created when "hotfix/*" branches are merged into it, and commits should not be added to it directly.

After creating the support branch, a "hotfix/*" branch with the next patch number should be created. These are similar to the "release/*" branches, as they should be reviewed in a pull request, and when they are merged.

Again, do not squash commits for hotfix branches before merging. That way, when support branch commits are merged back into the develop and main branches, the same commit SHAs appear, and the changes are shown as merged.

Git actions should publish the drafted release and delete the "hotfix/*" branch (similar to when a "release/*" branch is merged, see the above example).

...
      # START HOTFIX RELEASE RE-DRAFT
      - name: Check if releasing a hotfix
        id: is_hotfix_branch
        continue-on-error: true
        run: |
          PRBRANCH="${{ steps.payload_info.outputs.branch }}"
          FILTEREDBRANCHNAME=$(echo "$PRBRANCH" | grep "hotfix")
          echo "filtered_pr_branch_name=$FILTEREDBRANCHNAME" >> $GITHUB_OUTPUT

      - name: Get hotfix Tag from branch name
        id: get_hotfix_tag
        if: steps.is_hotfix_branch.outputs.filtered_pr_branch_name != ''
        run: |
          HOTFIXBRANCH="${{ steps.payload_info.outputs.branch }}"
          RELEASETAG=$(echo "$HOTFIXBRANCH" | sed -re "s/(\* )?hotfix\///;s/(\w+)\/.*/\1/g")
          echo "release_tag=$RELEASETAG" >> $GITHUB_OUTPUT

      - name: Get Last Tag created on this branch
        id: last_tag
        if: steps.is_hotfix_branch.outputs.filtered_pr_branch_name != ''
        run: |
          LASTTAG=$(git describe --tags | sed -re "s/-.+//")
          echo "last_tag_on_branch=$LASTTAG" >> $GITHUB_OUTPUT

      # Re-Draft Release with hotfix tag
      # "release-drafter" works by checking the changes of merged pull requests.
      # For support or main branch hotfixes, there is only one merged hotfix PR, which is only now available,
      # so we need to re-draft the release with the recently merged PR for release notes.
      - name: Draft Release with hotfix tag
        id: update_release_draft_with_hotfix_branch
        if: steps.is_hotfix_branch.outputs.filtered_pr_branch_name != ''
        uses: tiller1010/release-drafter@master
        with:
          tag: ${{ steps.get_hotfix_tag.outputs.release_tag }}
          last_tag: ${{ steps.last_tag.outputs.last_tag_on_branch }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      # END HOTFIX RELEASE RE-DRAFT
...

Merge support into main and develop

If a bug existed in a previous version, it is likely that it still exists in the latest version too. Similar to "release/*" branches that are merged into the main branch, support branches too should be merged into the main branch.

This happens by first creating a feature PR to merge a cloned support branch into the develop branch. There is, however, a higher potential for this to produce merge conflicts. In that case, a developer must resolve the conflict on their own, and push the resolved merge into the feature branch, set to merge into develop.

Now that the support branch is merged into develop, it can be merged into the main branch. This should be done in another release pull request.

I hope you decide to use git-flow in your projects! It adds much-needed sanity to the management of multiple versions of software.

actions1
2022-09-04 21:46:01