Tag-Based Production Releases with Azure DevOps: Lessons from the Trenches

Most deployment pipelines fail in the same way:
production gets deployed too easily.

A merge to main triggers a deploy.
A hotfix goes live unintentionally.
Nobody can answer, “What exact code is running in prod?”

After rebuilding my Azure DevOps pipeline from the ground up, I landed on a tag-based, infra-templated release model that trades a bit of upfront complexity for long-term safety and clarity.

This post documents what I built, why it works, and what I’d do again.


The Core Idea

Production releases should be intentional, immutable, and auditable.

In practical terms:

  • Merging code ≠ deploying to prod
  • Only Git tags create production releases
  • Deployment logic lives outside application repos
  • Pipelines are infrastructure, not app code

High-Level Architecture

I split responsibilities across two repositories:

App Repo (Tanolis.Corp.App)
└─ azure-pipelines.yml      ← thin entry point (locked)
   └─ listens for Git tags only

Infra Repo (Tanolis.Corp.Infra)
└─ pipelines/prod/
   └─ prod-release-template.yml  ← real build & deploy logic

Separation of Concerns

ResponsibilityLocation
When to deployApp repo (tags)
How to deployInfra repo (templates)
Build stepsInfra repo
Deployment stepsInfra repo
Release decisionGit tag

This separation is the single most important design decision.


Why Tag-Based Releases?

What tags give you automatically

  • Immutability
    A tag points to a specific commit. That release never changes.
  • Auditability
    You can always answer: “What version is live?”
  • Safety
    No accidental prod deploys from merges, rebases, or force pushes.
  • Clarity
    Release intent is explicit: someone created a tag.

What I deliberately avoided

  • Branch-based auto-deploys
  • “Merge to main = prod”
  • Release pipelines that require clicking buttons in the UI

The Thin Wrapper Pipeline (App Repo)

The App repo contains exactly one pipeline file at the root:

azure-pipelines.yml

Its responsibilities are minimal:

  • Register the pipeline with Azure DevOps
  • Listen for v* Git tags
  • Delegate execution to the Infra repo

It does not:

  • Build
  • Deploy
  • Know about Azure resources

This file is locked behind PRs and treated as infrastructure.


The Infra Template (Infra Repo)

All real work happens here:

  • Checkout app code
  • Build the Blazor WebAssembly app
  • Inject release metadata
  • Deploy to Azure Static Web Apps

Because this lives in a separate repo:

  • Platform changes don’t touch app code
  • Multiple apps can reuse the same template
  • Deployment logic evolves independently

This is how platform teams work — even in a solo setup.


Versioning: What Changed (and Why It’s Better)

Before this design, my UI displayed versions like:

main+ce16b0564f9...

That came from assembly metadata.

After moving to tag-based releases:

  • Assembly-time versioning no longer made sense
  • Static Web Apps don’t preserve pipeline variables at runtime

The fix: runtime version injection

During the pipeline, I write a simple file:

wwwroot/version.json

Example:

{ "version": "v2.0.5" }

The UI reads this at runtime.

Why this is better:

  • Works for static hosting
  • Decoupled from build tooling
  • Easy to extend (commit hash, timestamp, environment)

This is a subtle but important architectural upgrade.


The Release Flow (Day-to-Day)

Once everything is wired:

git checkout main
git pull

git tag v2.0.6
git push origin v2.0.6

That’s it.

No UI clicks.
No “deploy” buttons.
No ambiguity.


Rollbacks

Because releases are immutable:

  • Re-run the same pipeline → redeploy
  • Or re-tag a previous version (if policy allows)

Rollbacks become predictable instead of stressful.


Pros and Cons (Honest Assessment)

Pros

  • Zero accidental production deployments
  • Clear audit trail
  • Strong separation of app vs platform
  • Reusable across future projects
  • Scales from solo → team → regulated environments

Cons

  • Azure DevOps YAML has sharp edges
  • Initial setup is non-trivial
  • Pipeline must exist before tags will trigger
  • Requires “platform thinking” up front

For serious production systems, the tradeoff is worth it.


When This Pattern Makes Sense

Good fit for:

  • Solo founders building long-lived products
  • Gov / enterprise / audit-sensitive work
  • Teams that value safety over speed

Not ideal for:

  • Hackathons
  • Throwaway prototypes
  • “Move fast and break prod” cultures

Key Lessons Learned

  1. Pipelines are objects, not just YAML
  2. Entry-point pipelines must exist before tags
  3. Templates cannot trigger pipelines
  4. Production versioning belongs at runtime
  5. Infrastructure deserves PR discipline — even for one person

Final Takeaway

Production releases should be boring, predictable, and intentional.

This tag-based, infra-templated approach makes that the default.

It took longer to set up than a typical pipeline —
but I won’t be rebuilding it again.

FavoriteLoadingAdd to favorites

Comments

Leave a Reply


RECENT POSTS


Categories



Tags

ADO ai angular asian asp.net asp.net core azure ACA azure administration Azure Key Vault Azure Storage Blazor WebAssembly BLOB bootstrap Branch and Release flow c# containers css datatables design pattern docker excel framework Git guide HTML JavaScript jQuery json knockout lab LINQ linux powershell REST API smart home SQL Agent SQL server SSIS SSL SVG Icon typescript visual studio Web API window os wordpress


ARCHIVE


DISCLAIMER