Web Analytics

What’s happening at Joshu?

Implementing cascading merges in GitHub Actions (Part 1)

  • Zohar ZilbermanZohar Zilberman
  • November 2022
Merging

Cascading merges, also called automatic branch merging, is a feature I personally got to know in Bitbucket server almost 10 years ago. This post walks through how we implemented cascading merges in GitHub Actions at Joshu.

This post covers a very simple implementation that worked great for us, different organizations have different needs, do what works best for you.

What are cascading merges?

Cascading merges automatically merge changes from an earlier release to all releases following it.

This implies that the Git repo has some naming system for knowing when a push was made to a release branch and which releases are following it. One way of implementing is using a lexicographical order. For example, we might have branches named: release/1.0, release/2.0, release/3.0, release/3.1.

If a commit was pushed to release/2.0 we expect the same changes to be pushed into release/3.0 and release/3.1.

Also, we assume every release branch is created from a previous release branch, usually the one preceding it. You’ll see why this is important soon.

In the implementation below we’ll use a different naming system release/YEAR.MONTH so it would produce names like: release/2022.10, release/2022.11, release/2022.12.

We’ll dive deeper into the advantages of this naming scheme in Part 2.

How changes are pushed to future branches?

This is where things get interesting: For cascading merges to work we need to perform actual Git operations that ensure the desired end result.

Taking a Git commit pushed to release/2.0 and trying to “push” it to release/3.0 would probably not work because release/3.0 might have other changes pushed on top of it.

So, we can either use git cherry-pick or create a merge commit. We’ll go with the latter, which means we’ll simply merge release/2.0 into release/3.0.

This works because we assumed release/3.0 was branched off of release/2.0. Continuing this approach, we also need to merge release/3.0 into release/3.1 to make sure a change reaches all future release branches.

Apart from being simple, using a simple merge has three other advantages:

  • We can use git branch --contains <commit> to check which releases contain a change/fix.
  • If the cascading merge workflow fails due to an unexpected reason (network error, accidental stop, etc.) the next push to the same release branch would ensure all changes are merged forward, and not just the one that triggered the workflow.
  • If the merge forward fails due to a merge conflict, no future merge can succeed until the conflict is fixed. It sounds bad, but trying to resolve conflicts outside the original commit order can quickly become a nightmare.

What about the main/master branch?

When reaching the latest release branch, you’d usually want to merge it into your main branch, which is usually configured as the default branch and the one everyone checks out by default and rebase on top of.

Another option is to simply not have a main/master branch and define the latest release branch as the default one. We’ll see how this works in Part 2 of this post.

Creating a GitHub Actions workflow

Let’s create a new workflow in .github/workflows/merge-forward.yaml, each step is explained below:

						name: ⏩ Merge Forward
						on:
						  push:
						    branches:
						      - "release/*"
						jobs:
						  merge_forward:
						    name: ⏩ Merge Forward
						    runs-on: ubuntu-latest
						    steps:
						      - name: 🛎 Checkout
						        uses: actions/checkout@v3
						        with:
						          token: ${{ secrets.CI_PAT }}
						          fetch-depth: "0"
						      - name: 🪪 Set git name+email
						        run: |
						          git config user.name "Merge Forward"
						          git config user.email "nobody@example.com"
						      - name: 🔍 Find next release branch
						        id: branches
						        run: ./.github/scripts/next-release-branch.sh
						      - name: 🛣 Merge
						        run: |
						          current_branch="${{ steps.branches.outputs.current }}"
						          next_branch="${{ steps.branches.outputs.next }}"
						          if git branch -r --format "%(refname:short)" | grep "^origin/${next_branch}$"
						          then
						            echo "🪜 Merging ${current_branch} -> ${next_branch}"
						            git checkout "${next_branch}"
						            git merge "${current_branch}"
						            git push
						          else
						            echo "✋ No next branch, doing nothing"
						          fi
						

1. On… push… branches

This workflow runs on branches starting with release/: release/1.0, release/2.0, etc.

2. Checkout step

The first step checks out the code. This step uses a custom GitHub PAT (personal access token) that would allow us to push changes to the repo. You’ll need to enable full repo access.

Once the PAT is created, store it in a repo secret named CI_PAT.

3. Setting Git name and email

This is done for the merge commit we’ll be creating in step 5.

4. Find next branch

Finding the next release branch is saved as a separate script to make it easier to read and maintain. The contents of .github/scripts/next-release-branch.sh are:

						#!/usr/bin/env bash
						set -eu
						current_branch=`git rev-parse --abbrev-ref HEAD`
						echo "::set-output name=current::${current_branch}"
						release_trunk=`echo "${current_branch}" | cut -d '/' -f 2`
						echo "::set-output name=trunk::${release_trunk}"
						major=`echo "${release_trunk}" | cut -d '.' -f 1`
						minor=`echo "${release_trunk}" | cut -d '.' -f 2`
						if [[ "${minor}" = "12" ]]
						then
						    next_major="$(expr $major + 1)"
						    next_minor="1"
						else
						    next_major="${major}"
						    next_minor="$(expr $minor + 1)"
						fi
						next_minor=`printf '%02d' ${next_minor}`
						echo "::set-output name=next_major::${next_major}"
						echo "::set-output name=next_minor::${next_minor}"
						next_branch="release/${next_major}.${next_minor}"
						echo "::set-output name=next::${next_branch}"
						

As mentioned above, this example implements a monthly release branch naming like release/YEAR.MONTH. We’ll utilize this approach in Part 2 of this post.

This script uses ::set-output to write out variables we’ll use in the next step. Also, note that script doesn’t do anything but deducing the next release branch from the current one. Therefore it can be safely changed and executed if you want to try out a different branch naming system.

5. Merge step

The first two lines extract the outputs from the previous step:

						current_branch="${{ steps.branches.outputs.current }}"
						next_branch="${{ steps.branches.outputs.next }}"
						

Next, we check if the next branch exists in the repo’s remote (which is the repo in GitHub itself):

						git branch -r --format "%(refname:short)" | grep "^origin/${next_branch}$"
						

If the branch exists, this command returns 0 and we continue to the then part, which simply checks out the next branch, merges the current branch into it, then pushes the resulting merged branch to the remote.

						echo "🪜 Merging ${current_branch} -> ${next_branch}"
						git checkout "${next_branch}"
						git merge "${current_branch}"
						git push
						

Note that this works because we used the CI_PAT token when checking out in the first step. Using the default token provided by GitHub Actions wouldn’t allow for pushing changes to the remote repo.

The else part just prints a log so we can debug in case something doesn’t work correctly:

						echo "✋ No next branch, doing nothing"
						

Conclusion

The workflow above runs every time a commit is pushed to a release branch. Each release branch is responsible for merging itself into the next release branch until no more release branches are found.

Using the first branch naming example, pushing a commit to release/2.0 would trigger the following events:

  • The workflow would run for the release/2.0 branch, merging release/2.0 into release/3.0.
  • The workflow would run for the release/3.0 branch, merging release/3.0 into release/3.1.
  • The workflow would run for the release/3.1 branch, find out there are no more release branches, and stop.

If a merge fails, for example due to a conflict, the process stops where an issue was found and it has to be fixed manually. In Part 3 we’ll add Slack notifications for those events.

What’s next?

These were the basics for cascading merges, but we can do much better:

  • In Part 2 we’ll add a workflow for automatically creating release branches.
  • In Part 3 we’ll add Slack notifications to know when merges have succeeded or failed.