The Enterprise Traceability Problem#
Guessing whether v1.3.0 in production actually includes yesterday’s critical security patch is a dangerous game. Knowing exactly which version of an artifact is running in any given environment isn’t just a nice-to-have dashboard feature… it’s the foundation of a reliable release process. You can never afford to wonder if the build candidate QA just signed off on is truly the exact same binary you are deploying to users.
When building my homelab platform, I wanted that exact same level of ironclad traceability without the bloated, soul-crushing overhead of heavy enterprise tools. The goal was simple: zero manual version bumping. The CI/CD pipeline should be fully automated, predictable, and capable of gracefully progressing a release candidate through multiple gating environments until it safely reaches production.
Welcome to the versioning strategy overview! In this post, I’ll detail how I handle automated semantic versioning and release promotion across a 4-tier environment using tools purpose-built for the job.
The “Build Once, Promote Everywhere” Philosophy#
A common anti-pattern in CI/CD is tying builds to specific environments using branch strategies… for example, automatically compiling an image when code merges to a develop branch for Staging, and building a completely separate image when code merges to main for Production.
The fatal flaw with that approach? You are compiling a new image for every environment. Even if the codebase is identical, things like dynamic dependencies, runner toolchain updates, or external library versions can drift between the two builds. You lose the definitive guarantee that the image QA tested in Staging is the exact same image running in Production.
To solve this, my pipeline operates on strict immutable artifacts. I build an image exactly once at the very beginning of the CI cycle. From there, I promote that single, deeply verified image through every environment until it reaches Production. The underlying image hash never changes.
In practice, promotion means automatically computing the new semantic version tag and applying it to the existing image. Depending on the environment’s security requirements and how GitOps controllers like Argo CD are configured, this promotion often involves copying the exact same image hash from a lower-tier container registry to a dedicated higher-tier registry. Ideally, you want a fully segregated image registry for Production… with highly restricted access… so that only heavily vetted, explicitly promoted artifacts ever make it that far.
Two GitHub Actions: Generation and Progression#
To achieve a true “commit-to-production” release pipeline, the CI/CD system needs two distinct operations. It has to generate a brand new version when new code appears, and then it needs to promote that version suffix as it moves safely between lifecycle stages.
I orchestrate this using two custom GitHub Actions:
next-version: Used during the Continuous Integration (CI) phase. Its job is generation — it analyzes existing Git tags in the repository and computes the correct next version (usually by bumping the minor version and appending analphasuffix).promote-version: Used during the Continuous Delivery (CD) phase. Its job is progression — it takes a known existing prerelease version and safely advances its suffix state as the artifact successfully moves through deployment environments, culminating in a stable production tag.
The 4-Tier Environment Mapping#
In a typical production setting, you aren’t deploying straight to live users. For this pipeline, I designed a 4-tier environment structure. Each environment acts as a quality gate strictly bound to a semantic version prerelease suffix.
| Environment | Suffix | Tool Used | Output Example |
|---|---|---|---|
| Integration (INT) | alpha | next-version | v1.3.0-alpha.1 |
| Staging (STG) | beta | promote-version | v1.3.0-beta.1 |
| User Acceptance Testing (UAT) | rc | promote-version | v1.3.0-rc.1 |
| Production (PROD) | (none) | promote-version | v1.3.0 |
The Version Lifecycle in Action#
Visualizing the flow can really help lock down how an artifact matures.

A Realistic Scenario: Getting to v1.3.0#
Imagine the last stable version in the repository is v1.2.0. I merge a new feature PR to the main branch, kicking off an active development sprint:
Continuous Integration (INT) Over the course of the sprint, many fixes and small changes are pushed. Each push triggers the
next-versionaction. The CI generatesv1.3.0-alpha.1, thenv1.3.0-alpha.2, all the way up until an iteration I think is structurally sound:v1.3.0-alpha.15.Staging (STG) I deploy
v1.3.0-alpha.15to STG for deeper systems testing. The actionpromote-versionprocesses this alpha tag and smoothly generates the first beta:v1.3.0-beta.1. As the QA pipeline tests it, it finds bugs. I push fixes representing new alphas up tov1.3.0-alpha.18, and promote those to STG. The system auto-increments STG betas over time up tov1.3.0-beta.5.User Acceptance Testing (UAT) The
v1.3.0-beta.5build holds up perfectly. I push it to UAT where the final review happens.promote-versiontakes the beta and translates it to a release candidate:v1.3.0-rc.1. Wait—a typo is spotted at the last second. I fix it, generatingalpha.19, pushing tobeta.6, and finally promoting a new RC up to UAT:v1.3.0-rc.2.Production (PROD) The final sign-off is given. I run the PROD deployment targeting
v1.3.0-rc.2. Thepromote-versionaction is invoked with theis-stable: trueflag. It strips away all prerelease information, generating the pristine, stable release tag:v1.3.0.
Workflow Code Examples#
Below are snippets demonstrating how to arrange your .github/workflows to facilitate this exact pipeline in your own environment.
1. Generating the Initial Version (CI)#
This step executes entirely on code changes and establishes the base testing artifact.
name: 1. CI - Build and Integration
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required to see historical git tags for accurate bump calculations
- name: Generate next Alpha Version
id: generate-version
# Using a custom action to calculate the next semver increment
uses: anvaplus/github-actions-common/.github/actions/next-version@main
with:
version-type: 'alpha'
tag-repo: 'true'
- name: Report Build
run: echo "Generated integration build: ${{ steps.generate-version.outputs.new-version }}"
# Output: v1.3.0-alpha.12. Progressing through Environments (CD)#
Because artifact promotion isn’t always immediately triggered by code pushes (often it relies on a manual trigger, an approval gate, or automated integration test success events), here is an example of what the CD steps look like for your upper environments.
Deploy to STG (Beta)#
- name: Promote to Beta (STG)
id: promote-stg
uses: anvaplus/github-actions-common/promote-version@main
with:
version: 'v1.3.0-alpha.1' # Usually passed dynamically e.g. ${{ inputs.artifact_version }}
promote-type: 'beta'
tag-repo: 'true'
# Output: v1.3.0-beta.1Deploy to UAT (RC)#
- name: Promote to RC (UAT)
id: promote-uat
uses: anvaplus/github-actions-common/promote-version@main
with:
version: 'v1.3.0-beta.1'
promote-type: 'rc'
tag-repo: 'true'
# Output: v1.3.0-rc.1Deploy to PROD (Stable)#
For the final step to production, we no longer process a promote-type. Instead, we set the is-stable flag to forcefully clean the tag—giving us the pure semantic version.
- name: Promote to Stable (PROD)
id: promote-prod
uses: anvaplus/github-actions-common/promote-version@main
with:
version: 'v1.3.0-rc.1'
is-stable: 'true'
tag-repo: 'true'
# Output: v1.3.0Traceability as a Standard#
By adhering to this two-action strategy, I maintain a clean, linear, and thoroughly traceable history. I know precisely what code ran in which environment at any given time, bridging the gap between homelab experimentation and enterprise platform guarantees.
This versioning approach removes human error, keeps the Git history cleanly tagged, and aligns perfectly with modern GitOps principles… a crucial underpinning for deploying scalable and resilient CI/CD pipelines.
Stay tuned! Andrei

