📷 Photo: Buehler's Clock by Wendy Bayer
Versioning is crucial for maintaining the stability and consistency
of your GitHub Actions
Reusable Workflows
and
Actionss.
By implementing a versioning strategy with
git tags,
you can ensure that updates are clearly communicated,
reducing potential issues during automation.
💡 To learn how to create your own GitHub Actions Action and wrapper workflow, see this post
Git tags are references to specific commits and are the most common way to version your code. Using tags in conjunction with semantic versioning (MAJOR.MINOR.PATCH) provides clear communication about the stability and impact of changes in your workflows or actions.
- MAJOR version: Increments for breaking (non-backward-compatible) changes like renaming an action input or output
- MINOR version: Increments for backward-compatible new features or improvements like new optional parameters
- PATCH version: Increments for backward-compatible bug fixes
👀 Although Git tags themselves do not enforce semantic versioning, using this convention improves collaboration and helps others understand the state of the code.
You should version your workflows and actions after a successful merge into the default (e.g. main) branch, marking the release point with a tag.
You can also Slip version tags when backward-compatible changes are introduced, allowing users to automatically get the new updates without modifying their workflows.
👉 Here the term "Slip" means to force the re-tag of an existing tag to a different commit
Here you can use mutable tags.
To correctly tag your repository you must...
- Tag the commit
- Push the tag to the remote
-
Create the new tag:
-
For the Current Commit...
git tag <tagname>
For example...
git tag v0.2.5
-
For a Specific Commit...
git tag <tagname> <commit-hash>
For example...
git tag v0.2.5 e05e63b07706f9f985270aa29858b8b645a833ba
-
-
Push the new tags
git push --tags
To "Slip" or re-tag a tag that is already associated with
a commit, you must use the --force or -f option with
git tag and git push --tags.
-
Force the re-tag:
-
For the Current Commit...
git tag -f <tagname>
For example...
git -f tag v0.2.5
-
For a Specific Commit...
git tag -f <tagname> <commit-hash>
For example...
git tag -f v0.2.5 e05e63b07706f9f985270aa29858b8b645a833ba
-
-
Force the push of the re-tags
git push --tags -f
When versioning GitHub Actions workflows and actions, there are a few specifics to consider depending on whether you're versioning a workflow or an action.
Reusable workflows, like .github/workflows/copy_image.yml
can be fully versioned using a Git tag, as all the "code"
resides in the workflow file running in the calling repository.
When you define a reusable workflow, you can reference it in
other workflows using the uses keyword with an @ version tag:
uses: user/repo/.github/workflows/[email protected]GitHub Actions can be built using Dockerfile-based actions or composite actions. These need to be referenced both by their action directory and when their source code is checked out in an explicit Git checkout step, as described in this post on Dockerfile-based actions.
Both types of actions must be versioned within the source code repository where they are developed. To execute an action within your workflow, you need to reference it by a version tag.
For example...
...
- name: Checkout the action repository
uses: actions/checkout@v5
with:
repository: someuser/some-github-action-repo
path: action-repo
ref: v1.0.0
...
- name: Call the action
id: call-the-action
uses: ./action-repo/.github/actions/[email protected]
...If you don’t specify a version, the action will always use the
latest version (HEAD), which could introduce breaking changes.
That is why it is generally better to specify exact versions
to prevent any surprises.
In GitHub Actions, a wrapper workflow is a reusable workflow
that calls another action or workflow. When you want to ensure
that your wrapper workflow always uses a specific version of an
action or workflow, you can define a ref input parameter in the
wrapper workflow. This ref input specifies the Git reference
(e.g. a specific tag or branch) that should be checked out when
the action is called.
It is important to version the actions used in these wrapper workflows to avoid unexpected behavior from future updates. By referencing a specific version, you ensure consistency across different workflow runs.
⚠️ While you might think that you should assign a default value to your wrapper’srefinput, currently, there isn’t a tag that matches the latest change (at least one that exists yet)
For instance, in your wrapper workflow...
name: Some Wrapper
on:
workflow_call:
inputs:
...
ref:
description: "The git reference for the called action"
required: false
type: string
# the default is '' which is HEAD
...Then this reference is used for the git checkout of the called action...
For example...
...
- name: Checkout the action repository
uses: actions/checkout@v5
with:
repository: someuser/some-github-action-repo
path: action-repo
ref: ${{ inputs.ref }}
...
- name: Call the action
id: call-the-action
uses: ./action-repo/.github/actions/delete-docker-hub-repository
...This ensures that the action called by the wrapper is consistent and won’t change unexpectedly when the wrapper itself is executed.
💡 You do not need to version the action in the uses
because it will be at HEAD by default which will be
at ${{ inputs.ref }} from the actions/checkout@v5
Loose versioning, for lack of a better term, is a strategy that allows you to automatically pick up backward-compatible changes without needing to modify your workflows or actions each time a change is made. This involves "slipping" existing and used version tags, particularly minor and patch versions, to point to the latest merged commit that includes the new changes.
There are benefits and costs to this approach.
🕛 💰 The main benefit of this approach is immediately picking up new features and bug fixes without manual intervention, saving time and money and ensuring your workflows stay current.
💥 Not all changes that are labeled as backward-compatible actually are. Sometimes, changes that are supposed to be non-breaking can unexpectedly break your CI/CD pipeline. For example, a minor change in an action called by a Reusable Workflow could unintentionally cause issues with existing configurations or inputs. As a result, while loose versioning usually saves you time, it introduces a risk of unexpected breaking changes requiring time and effort of updating the calling workflows.
✨ To mitigate this, it is crucial to test and carefully manage your actions and workflows, especially when relying on loose versioning
-
Use patch and minor version tags (e.g.
v1.2.0,v1.2)- Not so much major tags (e.g.
v1)
- Not so much major tags (e.g.
-
When backward-compatible changes are made (such as bug fixes or minor feature additions), you...
- Create a new patch tag e.g.
v1.2.1for the latest commit - Slip the minor tag e.g.
v1.2to the latest commit
- Create a new patch tag e.g.
This ensures that workflows automatically pick up the changes without requiring manual updates.
For example...
- Your calling workflows use the minor version
v1.2of your reusable workflows and actions - You have an action
v1.2.0(v1.2) in your workflows - A backward-compatible feature is introduced and tagged
v1.2.1 - Instead of updating every workflow, you slip the
v1.2tag to the same latest commit asv1.2.1, and all workflows that referencev1.2(@v1.2) automatically get the new feature
One scenario where loose versioning can become tricky is when you have a wrapper workflow that calls another action.
The challenge here is that while you want to update the actions that your wrapper workflow calls, you don’t want to break workflows that depend on a specific version of that wrapper.
In some cases, you can actually use an after-the-fact versioning
"hack" to make breaking changes to called actions and use git
and slipping tags to make them not breaking changes
and backwards-compatible after the changes are merged.
👉 This works for the previous minor (and patch) version and assuming that the wrapper workflow itself (inputs/secrets/outputs) has only backwards-compatible changes.
-
In the PR making the breaking changes to the action, in the very first commit, "pin" the
refinput default value to the current patch tag e.g.v1.2.0(v1.2)name: Some Wrapper on: workflow_call: inputs: ... ref: description: "The git reference for the called action" required: false type: string # # Pin previous version of the action to avoid breaking changes default: v1.2.0 ...
For example, imagine this first pinning commit has commit sha
e05e63b07706f9f985270aa29858b8b645a833ba -
After committing the first commit with the pinned version of the action, remove the "pinning" by removing the
refinput's default value added in the first commitname: Some Wrapper on: workflow_call: inputs: ... ref: description: "The git reference for the called action" required: false type: string ...
-
Make the non-backwards-compatible changes in the action and the backwards-compatible changes in the wrapper workflow in the PR
-
After merging the PR, slip the current patch version (the one pinned in the first commit of the just-merged PR) e.g.
v1.2.0to that first commit pinning the action to that tag minor versionFor example, we would slip
v1.2.0toe05e63b07706f9f985270aa29858b8b645a833ba -
Create the new patch tag e.g.
v1.2.1at the latest merge commit -
Slip the minor tag e.g.
v1.2to the latest merge commit
🔖 You can see an example of this in this PR
Implementing versioning in GitHub Actions using Git tags and semantic versioning helps ensure stability and clarity, allowing for better communication and management of updates. It allows for balancing the benefits of loose versioning with the potential risks of unexpected breaking changes.