Skip to main content
Stop Rebuilding Your Images: The "Build Once, Promote Everywhere" Manifesto

Stop Rebuilding Your Images: The "Build Once, Promote Everywhere" Manifesto

Andrei Vasiliu
Author
Andrei Vasiliu
Romanian expat in Italy. Platform Engineer by trade, homelab builder by passion. Documenting every step of building enterprise-grade infrastructure at home.
Table of Contents
Self-Hosting the Blog - This article is part of a series.
Part 2: This Article

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.

FAIR WARINIG: this is not a casual, easy-to-read post. It represents a dense combination of automated semantic versioning and strict artifact promotion designed for production-grade reliability.

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:

  1. 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 an alpha suffix).
  2. 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.

EnvironmentSuffixTool UsedOutput Example
Integration (INT)alphanext-versionv1.3.0-alpha.1
Staging (STG)betapromote-versionv1.3.0-beta.1
User Acceptance Testing (UAT)rcpromote-versionv1.3.0-rc.1
Production (PROD)(none)promote-versionv1.3.0

The Version Lifecycle in Action
#

Visualizing the flow can really help lock down how an artifact matures.

env_promotion_diagram

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:

  1. Continuous Integration (INT) Over the course of the sprint, many fixes and small changes are pushed. Each push triggers the next-version action. The CI generates v1.3.0-alpha.1, then v1.3.0-alpha.2, all the way up until an iteration I think is structurally sound: v1.3.0-alpha.15.

  2. Staging (STG) I deploy v1.3.0-alpha.15 to STG for deeper systems testing. The action promote-version processes 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 to v1.3.0-alpha.18, and promote those to STG. The system auto-increments STG betas over time up to v1.3.0-beta.5.

  3. User Acceptance Testing (UAT) The v1.3.0-beta.5 build holds up perfectly. I push it to UAT where the final review happens. promote-version takes 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, generating alpha.19, pushing to beta.6, and finally promoting a new RC up to UAT: v1.3.0-rc.2.

  4. Production (PROD) The final sign-off is given. I run the PROD deployment targeting v1.3.0-rc.2. The promote-version action is invoked with the is-stable: true flag. 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.1

2. 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.1

Deploy 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.1

Deploy 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.0

Traceability 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

Self-Hosting the Blog - This article is part of a series.
Part 2: This Article