TL;DR Most branching strategy advice assumes you can deploy to everyone at once and roll back in minutes. PLC software ships to physical machines that run a specific version for years, sometimes on hardware that makes upgrading a major version a service project rather than a download. That constraint rules out most of the popular models and points at long-lived release branches. This post walks through the options, then covers the part that actually costs teams time: what parallel release lines do to your pipeline triggers, version derivation, and library resolution, and how to retrofit the model onto a repository that has already shipped.


The branching concepts here apply broadly, but the tooling references are specific to TwinCAT and the Beckhoff ecosystem. We build CI/CD and package management tooling for TwinCAT teams, so the version-management problems below are ones we have had to solve in our own products rather than reason about in the abstract.

This is the third post in our CI/CD for TwinCAT series. If you do not have a pipeline running at all yet, the build tooling landscape post covers those options first; this one assumes you have something and are deciding how your branches should feed it. If you would rather go straight to the comparison, there is a summary table near the end.


Why machine software needs a deliberate branching strategy

When teams first set up CI/CD, branching strategy is usually not on the agenda. The immediate goal is getting a pipeline to run at all: point it at main, piece together a build script (copied from somewhere, generated by an LLM, or written from scratch), call it done. And for a while, sometimes a surprisingly long while, that is enough.

If your project is genuinely a single product for a single customer and you will never need to maintain more than one version simultaneously, that is fine. The rest of this post is for teams that cannot make that assumption, which in practice means most machine builders once they have more than one customer or more than one machine variant in the field.

Web and SaaS teams can push a commit and have it live for every user within minutes; if something breaks, they roll it back. PLC software ships to a physical machine that may run that exact version for five years. A packaging line in a factory is not going to take a software update every two weeks. The customer has a maintenance window once a quarter, if you’re lucky. Meanwhile, three other customers are running v1.2.1, one is still on v1.1.4, and you are halfway through v2.0.

It goes further than that. A new software release often depends on new or different hardware components: a different sensor, a new I/O module, an updated drive. The machine in the field is part of the release. Updating a customer from v1.x to v2.x may require a service visit, physical hardware changes, rewiring, and a re-commissioning process. “Just update to the latest version” can be physically impossible without a significant modification to the installed machine, and that is entirely the customer’s call, on their timeline and budget.

This means the installed base is not a temporary state you can ignore. Some machines will stay on v1.x indefinitely and still need to be supported. The need tends to surface at one of these moments:

  • Machine A has been delivered and commissioned. You start adding new features for Machine B (a different variant, different hardware, different conveyor layout). Machine A’s code is “done” but the customer will call when something is wrong. You cannot ship Machine B’s code to Machine A.
  • You shipped v1.0 six months ago. Three customers are running it. You are halfway through v2.0. A customer reports a bug in v1.0. You open your repository and there is no branch representing that version. Fixing it cleanly means backporting against a raw commit, or asking the customer to wait.
  • You add a shared library to your v2.0 project. The new version of that library has a breaking API change. Your v1.x build breaks. Your single pipeline is now broken for the wrong reasons, on a branch you are not even actively developing.

Libraries are where the problem compounds. Once you have v1.x and v2.x release lines, every shared library your project depends on has to follow the same pattern, and your dependency resolver has to understand that it did.

None of this is a CI/CD tooling problem. It comes from never having decided what the relationship between your branches and your releases is, and the pipeline only makes the gap visible faster. Your triggers, your artifact versions, and what a resolver hands back when a project asks for “the latest v1” all follow from the branching model, which is why it is worth choosing deliberately rather than discovering the choice under pressure.


A tour of the strategies

Trunk-based development

Trunk-based development is Google and Meta’s model: everyone commits to a single main, feature flags hide incomplete work, and the trunk is always deployable. It assumes very fast automated tests, a mature feature-flag system, and the ability to serve every user from one artifact. None of those hold for PLC software: builds need real Windows hardware, and “deploy to all machines simultaneously” is not a button that exists. Worth knowing the pattern, but not a fit here. If you work out how to retrofit a conveyor section via feature flag, do let us know.


GitHub Flow

GitHub Flow (Scott Chacon, 2011) distils everything to one rule: main is always deployable, feature branches are short-lived, merge and deploy immediately. For a SaaS team it is hard to argue with. Its limitation for machine software is that there is no concept of maintaining a released version once you have moved on: after you tag v2.0, v1.x is an old commit with no structured place to develop a v1.2.1 hotfix. Most teams start here and outgrow it with their second customer.


Git Flow

Git Flow was introduced by Vincent Driessen in 2010 and became widely popular, particularly in Java and enterprise software communities. It defines a strict branching model with two permanent branches: main (always reflects production state) and develop (integration branch for the next release).

gitGraph
   commit id: "init"
   branch develop
   checkout develop
   commit id: "dev work"
   branch feature/conveyor
   checkout feature/conveyor
   commit id: "conveyor POU" type: HIGHLIGHT
   checkout develop
   merge feature/conveyor
   branch "release/1.0"
   checkout "release/1.0"
   commit id: "version bump" type: HIGHLIGHT
   checkout main
   merge "release/1.0" tag: "v1.0"
   checkout develop
   merge "release/1.0"
   checkout main
   branch "hotfix/1.0.1"
   checkout "hotfix/1.0.1"
   commit id: "critical fix" type: HIGHLIGHT
   checkout main
   merge "hotfix/1.0.1" tag: "v1.0.1"
   checkout develop
   merge "hotfix/1.0.1"

main and develop are permanent (●). feature/*, release/*, and hotfix/* branches are short-lived and deleted after merging (◆).

The two permanent branches give a clear separation between “what is in production” and “what is being built next,” with explicit hotfix/* branches for urgent production fixes. Driessen himself added a note to the original post in 2020 observing that the model suits versioned software that cannot easily be rolled back, which describes machine software well.

The catch is that Git Flow’s release/* branches are temporary. They exist to stabilise a release and are deleted after merging, so there is no structure for maintaining v1.x while actively developing v2.x for years. If your customers may stay on v1.x indefinitely, you are extending Git Flow past its original scope. If the separation is what appeals to you, Release Flow keeps it and makes the release branches permanent.


GitLab Flow

GitLab Flow is a framework rather than a single fixed model. It takes GitHub Flow as its baseline and defines two extension patterns for teams that need more structure.

With environment branches: Code flows from main downstream through environment branches that each represent a deployment target, for example mainstagingpre-productionproduction. Commits only flow downstream, which ensures everything is tested in every environment before reaching production. Hotfixes are typically developed on a feature branch, merged into main, and then promoted downstream.

gitGraph
   commit
   branch staging
   commit
   checkout main
   branch production
   checkout main
   branch "feature/auth"
   checkout "feature/auth"
   commit type: HIGHLIGHT
   checkout main
   merge "feature/auth"
   checkout staging
   merge main
   checkout production
   merge staging
   checkout main
   branch "feature/conveyor"
   checkout "feature/conveyor"
   commit type: HIGHLIGHT
   checkout main
   merge "feature/conveyor"
   checkout staging
   merge main

main, staging, and production are permanent (●). feature/* branches are short-lived (◆). Code flows in one direction only: features land on main, then get promoted downstream through each environment.

With release branches: For teams that need to maintain multiple versions simultaneously, GitLab Flow also supports cutting v1 and v2 branches (or release/1.x, release/2.x) from main and maintaining them independently. In this mode it converges on what Release Flow formalises, so the diagram in the next section covers both.

Environment branching earns its keep when the artifact is not just code. If your software ships alongside a configuration database or another stateful component, parallel version branches get awkward fast, because the schema on release/1.x diverges from the one on release/2.x and you end up maintaining two migration histories. A single promotion path avoids that entirely.

Machine software is usually self-contained: code plus its library dependencies, with no external database evolving alongside it. The constraint that makes environment branching attractive therefore tends not to apply, which leaves the release-branch mode as the more relevant pattern.


Release Flow

Release Flow and GitLab Flow’s release-branch mode look nearly identical structurally. The difference is in the conventions and what the pipeline derives from them. Release Flow specifies a consistent naming scheme (release/1.x, release/2.x), defines exactly where hotfixes go (branch off release/1.x, cherry-pick to main), and makes version derivation explicit: the version number comes from the branch name, not a manually maintained variable. Those conventions are what lets your pipeline and your package manager behave predictably across all active version lines.

The name “Release Flow” was coined by Microsoft’s Azure DevOps team in a 2018 blog post describing how they develop their own product. The underlying pattern (release branches off a stable main) is older and is documented in the Continuous Delivery book (Humble & Farley, 2010) under “release branching.” releaseflow.org is a good modern reference for the general strategy.

The model is straightforward. Development happens on main. When you are ready to ship a release, you cut a release/1.x branch. Bug fixes and maintenance for that version happen on the branch and get cherry-picked back to main. When you are ready to ship v2.0, you cut release/2.x and the cycle repeats. Both branches then run in parallel indefinitely.

gitGraph
   commit id: "dev work"
   commit id: "v1 feature complete"
   branch "release/1.x"
   checkout "release/1.x"
   commit id: "v1.0" tag: "v1.0"
   commit id: "v1.1" tag: "v1.1"
   checkout main
   commit id: "v2 features"
   commit id: "v2 complete"
   branch "release/2.x"
   checkout "release/2.x"
   commit id: "v2.0" tag: "v2.0"
   checkout main
   commit id: "ongoing dev"
   checkout "release/1.x"
   commit id: "v1.2" tag: "v1.2"

main, release/1.x, and release/2.x are all permanent (●). Each release branch is maintained for as long as machines running that version are in the field.

Microsoft’s own variant is optimised for a cloud service that ships every sprint to the same production environment: hotfixes always go to main first and are then cherry-picked to the current release branch. For machine software, the more relevant variant is long-lived version branches: release/1.x might be maintained for years while release/2.x development runs in parallel, because updating a machine in the field often involves a formal commissioning process, and in some cases hardware changes that make a major version upgrade a significant project in its own right.

The development cycle step by step:

  1. All feature work happens on main. CI runs on every push, using the same build and test pipeline that will eventually run on the release branch.
  2. When the team declares feature-complete for v1, cut release/1.x from main. That branch is the v1 version line from now on.
  3. Widen your CI trigger to main and release/**. The same pipeline definition runs on both; only the versioning differs.
  4. A bug is found on a machine running v1.1. Fix it on release/1.x, tag v1.2, let CI build the artifact. Cherry-pick the fix back to main so it does not regress in v2.
  5. When v2 features are ready, cut release/2.x from main. Now two release branches run in parallel, each with their own CI and their own artifact stream.
  6. A fix that affects both v1.x and v2.x needs to be applied to both branches. That is double work. It is also the correct answer: you have customers on two versions, and they both deserve the fix.

What this actually means for your pipeline

The branching model is a diagram. The pipeline is where it either works or quietly does not, and this is the part teams consistently underestimate. Five things change the moment a second release line exists.

Triggers have to match every release line. A pipeline wired to main is the default in every CI system, and it keeps working perfectly after you cut release/1.x. That is exactly what makes the failure easy to miss: nothing errors, pushes just produce no build. The trigger needs a glob.

# GitHub Actions
on:
  push:
    branches:
      - main
      - 'release/**'

In Jenkins the equivalent is a multibranch pipeline with release/** added to the branch discovery filter; in Azure Pipelines it is an entry under trigger.branches.include. Whichever system you use, verify it by pushing an empty commit to a release branch and confirming a build appears. Assuming it fires is how you find out during a hotfix.

Version numbers have to be derived, not stored. A version in a variable that someone edits by hand will be wrong on at least one branch at all times. Derive it from the branch and its own tag history instead.

# On release/1.x: ask git for the newest tag on THIS line and the
# distance from it, e.g. v1.2.0-14-gb3f21ac -> 1.2.0.14
git describe --tags --match "v1.*" --long

The tempting shortcut is to use your CI system’s build number as the last component. Avoid it: that counter is global, so both release lines draw from it, and your v1 artifacts come out as 1.0.0.41, 1.0.0.58, 1.0.0.77 with unexplained gaps. Worse, migrating or resetting the CI server can make a new build number lower than an existing one, at which point your package manager considers a fresh build older than what is already published. A per-branch commit distance is monotonic within its line and survives a CI migration.

The version has to be written into the project before compiling. This one is specific to TwinCAT and it catches people. A library carries its version in the <ProjectVersion> element of its .plcproj, not in the artifact filename, so tagging the Git commit is not enough. If nothing sets that element before the build step, you get a .library still reporting TwinCAT’s default 0.0.0.1 while your tag says 1.2.0, and every project resolving against it sees the wrong number. Deriving the version is only half the job. Writing it into the project is the other half, which is why our build CLI does that step itself rather than leaving it to the pipeline author.

Artifacts need a per-line notion of “latest”. Builds from release/1.x and release/2.x have to coexist in storage and be independently addressable. Each line has its own latest: the newest version that is API, ABI, and hardware compatible within that line. A registry that tracks a single global latest across all versions collapses that distinction, and the collapse is silent until a v1 consumer gets handed a v2 artifact.

Your libraries branch too. This is the part that multiplies. Once the machine project has v1.x and v2.x lines, every shared library it depends on needs the same treatment: library/1.x for the machines in the field, library/2.x for new development. Release lines have to be cut in dependency order, bottom up, and every project consuming a library has to say which release line it is on rather than just asking for the newest build. A dependency resolver that has no concept of branched release history cannot give a correct answer here, no matter how carefully the branches are named.

There is also a resourcing consequence worth planning for. Two active release lines means two independent build streams competing for the same Windows build agent and the same test hardware, so a v1 hotfix ends up queued behind a v2 feature build. That is the point where a single build agent stops being enough.

None of this is hard once the branching model is settled. The failure mode is building CI for main only, shipping v1.0, cutting release/1.x, and discovering the gaps one at a time under pressure.


Retrofitting a repository that already shipped

Almost nobody gets to start fresh. The realistic situation is a single main, v1.0 running at two customers, and several months of v2 work already stacked on top of it. Release Flow can be applied retroactively, and Git makes it cheaper than it sounds, but the order of operations matters.

1. Identify the commit that was actually delivered. If you tagged the release, you have it. If not, this is the archaeology step: match the artifact on the machine against your history using the build date, the version recorded in the project file, or the changelog. Tag it now, even if the tag is an educated guess that you note as approximate in the commit message. Everything after this depends on having a named point to branch from.

2. Cut the release branch from that commit, not from main.

git branch release/1.x v1.0.0
git push -u origin release/1.x

That is the entire structural change. The branch deliberately does not contain your v2 work, which is what makes it an honest representation of what the customer is running.

3. Confirm it still builds before you rely on it. A branch cut from an eight-month-old commit frequently fails CI on the first attempt, and the reasons are informative. Usually a library reference is set to * and now resolves to something newer than what is installed on the machine, or the pipeline definition itself has moved on since that commit. The build tooling on your nodes has generally moved on too, which is a good argument for installing those CLIs at a pinned version rather than whatever happens to be latest. Pin your library references to explicit ranges while you are looking at them, because this is the last quiet moment you will get to do it.

4. Widen the trigger, then prove it works. Push an empty commit to release/1.x and check that a build appears and that the artifact is versioned 1.x rather than 2.x. Doing this now takes ten minutes. Doing it while a customer is waiting on a fix takes considerably longer.

5. Write down where fixes originate. For machine software the answer is usually “on the oldest affected release branch, then cherry-pick forward,” which is the opposite of Microsoft’s variant. Either convention works, but a team that has not agreed on one will produce fixes that exist in v1.x and silently regress in v2.x.

6. Leave main’s history alone. There is a strong temptation to rewrite history so it looks like you had been doing this all along. Resist it. Everyone’s clones break, your existing tags stop matching, and it buys nothing: the branch you cut in step 2 already gives you everything you need going forward.

The expensive step is almost never step 2. It is step 3, because a release branch that cannot build is not a release branch, and finding out which of your library references have been floating on * for a year is the kind of discovery that reorganises a sprint. That is also the honest reason this retrofit gets postponed: the work is unpredictable until you attempt it. If you would rather not spend that sprint discovering which of the six steps is the hard one for your repository, we do this with teams.


Semantic versioning and package manager compatibility

TwinCAT gives you exactly two ways to reference a library version: an exact four-part version, or * for whatever is newest at build time. There is nothing in between. No way to write “the latest v1, but never v2.” With a single release line that limitation is survivable, because exact-or-latest covers the situations you actually have. The moment release/1.x and release/2.x both exist, the thing you need to express is precisely the thing the reference syntax cannot express.

This is where a package manager earns its place, and the axis it has to add is not a range operator but the release line itself. Twinpack’s approach is to make the branch part of every reference. In .Zeugwerk/config.json each package carries a name, a version, and a branch:

{
  "name": "MyConveyorLib",
  "version": null,
  "branch": "release/1.x"
}

A version of null still means “latest”, but it now means latest on that branch, because the branch narrows the candidates before the highest version among them is chosen. A machine project pinned to release/1.x keeps receiving v1 maintenance builds and never gets handed 2.0.0, and nobody has to hand-edit an exact version after every patch.

The default branch is main, and that is the detail worth internalising: a reference that does not name a branch follows your active development line. That is the right default for a library you are actively building against, and the wrong one for a machine that shipped eighteen months ago.

This is also why general-purpose tooling tends to fail here. Many git-native resolvers work out the latest version of a library by walking tags on the default branch. Tag 1.2.3 on release/1.x and 2.0.0 on release/2.x, and a resolver that only inspects main either never sees 1.2.3 or treats 2.0.0 as having superseded it.

TwinCAT’s * version placeholder

TwinCAT allows library references to specify * as the version, meaning “resolve to the latest version available at build time.” In a single-branch world this is predictable: latest is latest. Once you have release/1.x and release/2.x in play, * has no meaningful answer. Depending on how the resolver walks the repository, it may hand back 2.0.0 to a project written against the v1.x API that is running on a machine which cannot physically be upgraded to v2.x hardware. The build succeeds; the interface mismatch surfaces on the machine. At a customer site. On a Friday.

Using * is effectively opting out of version management - the “we’ll figure it out when we get there” of dependency resolution, and “there” is usually a machine running production. It works until you introduce a second major version, at which point it becomes a runtime lottery. The fix is not a cleverer version string, because TwinCAT does not offer one. It is to move the decision up a level: name the release line, and let “latest” mean latest within that line. That is the problem Twinpack, our open-source package manager for TwinCAT, was built to solve.


Picking the right strategy

Each row below is a constraint that commonly decides which strategies stay viable. Find the rows that describe your situation and see which columns survive them.

Trunk-basedGitHub FlowGit FlowGitLab Flow (env)GitLab Flow (release)Release Flow
Hotfix to old deployed version
Concurrent versions in the field
Stateful artifact (DB / config)
Staging gate before production
CI/CD complexityLowLowHighMediumMediumMedium
When you shipEvery commitEvery commitPer releaseControlledPer releasePer release
Fit: web / SaaS
Fit: machine / PLC software

✓ a defined part of the strategy  ·  ◑ possible, but you are extending the strategy yourself  ·  ✗ not something the strategy addresses

One row deserves a caveat rather than a mark. The stateful-artifact row is the only place environment branching clearly wins, and it is also the row least likely to apply to machine software: if your project is code plus library dependencies with no database schema evolving alongside it, you can ignore that row entirely. If you do ship a configuration database that has to migrate between versions, it may be the row that decides the whole question, and it is worth weighing against everything else in the table before settling on release branches.

If you have machines in the field that need independent version maintenance, Release Flow or GitLab Flow’s release-branch mode will create the least friction going forward. The remaining questions are which one fits your team’s working style, and whether your tooling can actually resolve versions across branched release lines rather than assuming a single history.


Questions about branching strategy for TwinCAT, or want a second opinion on how your release lines and your pipeline fit together? Get in touch. We also run training for teams who would rather work through this with their own repository open in front of them.