Semantic Releases (Part 1): An Example Process

Gordon Messmer
8 min readJul 28, 2023

--

A source code repository with semantic versions

Briefly, semantic versions consist of three dot-separated numbers: MAJOR.MINOR.PATCH. If the patch number increases, the update fixes bugs but does not change the interfaces, so compatibility is not affected. If the minor number increases, the update adds features, but is fully compatible with all of the old interfaces. It may also include bug fixes. If the major version increases, then interfaces have been removed or changed in incompatible ways, which may break compatibility. Major version updates may also have both new features and bug fixes.

Semantic versions can label sequential updates, but they are also common with software that has multiple concurrent release series.

Let’s take a look at one possible software development strategy that supports multiple concurrent release series with semantic versions. (This section has a lot of diagrams to illustrate the process, but not every step is illustrated. Interested readers can view the same process with more steps by following this link.)

Early on in the development of your project, the history of a developer’s source code might be fairly linear. Each change has been recorded relative to the state of the code before it. The repository will have just one “main” branch.

However, developers can improve their workflow by using feature branches to develop changes. Feature branches are a way to logically group a set of related changes until the developer is ready to merge them into the main branch. At that point, the developer can run tests on the state of the code including those changes, determine that they are of suitable quality, and then merge them.

Eventually, the project will be feature complete, and the developer will want to release their software to their users. In order to support releases with versions that communicate meaning via semantic versions, the developer can create two release branches. First, they create a branch named “release-1”, which is a major-version stable release branch. On top of that branch, they can create another branch named “release-1.0” which is a minor-version stable release branch.

Now that they have two types of stable release branches, we can discuss how future changes in the main branch can be handled. In order to maintain semantic releases, any change that is a bug-fix only can “bubble up” into all actively maintained branches. Any change that adds a new feature can “bubble up” into all actively maintained major-release branches, but shouldn’t bubble up into minor release branches. If a new feature were added to a minor release branch, then any future release produced from that branch would have a new feature, and that’s not what users will expect from that release series. The new feature will be released later, when a new minor release branch is created. Finally, any change that removes a feature or changes it in an incompatible way does not bubble up at all. Those changes will be released in the future only after the developer creates a new major release branch.

That can be hard to picture, so let’s look at some illustrations.

After releasing version 1.0.0 of their software, users report a bug. The developer creates a new branch where they make changes that resolve the issue. After some work, they test the changes and then merge those changes back into the main branch.

After release branches are created, a change is added to the main branch

Because this is a bug fix, the changes can be merged into the major release branch. In git, this is accomplished by creating a new branch in the major release branch, cherry-picking those changes into the new branch, and then testing them again. Finally they are merged.

The change is merged to “release-1”

Next, the developer repeats the process. They create a new branch in the minor-release branch, cherry-pick the changes, test them, and merge them. At this point, the developer can release version 1.0.1 by building the “release-1.0” branch.

The change is merged to “release-1.0”

With the bug fixed, users ask for a new feature in the software. The developer begins by creating a new branch from “main”, then developing the new feature in that branch. As always, they test the feature and merge into the main branch.

Once the feature is merged into “main”, it can bubble up into the major release branch. Just as before, the developer creates a new branch, cherry-picks the changes, tests the new state, and then merges into the “release-1” branch.

In order to publish the software, the developer then creates a new branch named “release-1.1”. They can build that branch to create version 1.1.0.

A new change is merged to “release-1” and a “release-1.1” branch is created

The new version attracts new users, who report another bug in the program. The developer checks each release branch they are maintaining, and determines that the bug affects both release branches.

They start by creating a new branch from “main” and developing a fix there. The fix is then tested and merged into “main”.

Because this is a bug fix change, it can bubble up into all actively maintained branches. So, the developer creates a new branch in “release-1”, cherry-picks the change, tests the result and merges. Then they do the same for “release-1.1”, and for “release-1.0”.

Once the bug fix is merged, they can build and publish versions 1.1.1 and 1.0.2.

A bug fix is merged to all release branches

As time goes on, the developer reviews the set of features and decides that a feature is unused and creates a lot of unnecessary complexity in the code. They decide that they’ll remove that feature.

As always, they create a branch where they remove that feature. The result is tested and then merged into the main branch.

This change can’t bubble up into any release branches. Instead, in order to publish this change, the developer creates a new major release branch, “release-2”, and as they did with the first major release branch, they create a minor release branch named “release-2.0”. They’ll use that branch to build and release version 2.0.0.

A breaking change is merged to main, and a new major and minor release branch are created

Again, this release attracts new users, and they file new bug reports. Again, the bug affects all versions, but wasn’t noticed until users started using the software in new ways.

The developer creates a new branch to develop a fix, test the change, and merge into “main”.

This change can bubble up into all active branches, so the developer creates a new branch from “release-2”, cherry-picks the changes, tests, and merges. Then they branch, cherry-pick, test, and merge into “release-2.0”. They branch, cherry-pick, test, and merge into “release-1”. And then into “release-1.0”. And then into “release-1.1”.

Finally, they can publish 2.0.1, 1.1.2, and 1.0.3.

A bug fix is merged to all release branches

As we can see from the last bug fix, the complexity of managing this workflow grows very quickly. One bug fix creates 6 rounds of testing! That brings us to the question of how a developer can control the growth in the complexity of that work.

When we were illustrating the workflow, it appeared that every successful change to the main branch resulted in new releases. In practice, that won’t usually be the case. For one, developers may triage bug fixes based on the severity of the problem and the likelihood that their customers will actually be impacted. They may judge some bug fixes as insufficiently high priority to do the work required to merge from the main branch into active releases. Developers may also want to limit the number of branches that require maintenance at any given time. Frequently, large projects create branches and releases at regular intervals, rather than in response to successful developments in the main branch.

For example, let’s imagine that except for critical security issues which are published on demand, the developer commits to a major release cadence of 2 years, with a 3 year life cycle. Within a major release, there is a minor release cadence of 6 months with a life cycle of one year. Each major release will receive 5 minor releases. Within a minor release, bug fix releases have a monthly release cadence.

A timeline of actively supported branches would look like this diagram:

Major and minor release life cycles overlap

This hypothetical lifecycle results in a maximum of two minor release series being maintained within a major release at a time. Including the major-release branch in the workflow, that’s a maximum of three code branches per major release.

There are also a maximum of two major releases maintained simultaneously. They overlap for one year. During the year of that overlap, there will be three minor releases maintained simultaneously: two minor releases from one major and one minor release in the other. Including the major-release branches in the workflow, there will be five active code branches at a time.

By defining release cadence and life cycle on a predictable schedule, rather than in reaction to the work they complete, a developer can limit the maximum number of branches and releases that they maintain at a given time. That also helps keep their workload fairly constant, which provides them the benefit of relatively constant staffing requirements.

We’ve looked at a simple process for maintaining concurrent semantic releases. In part two, we’ll start to explore the benefits delivered by the development overhead incurred by this process.

--

--