CI-CD · ENGINEERING-STANDARDS

The Versioning & Deploy Pipeline I Give Every Repo

The shape of the problem

This is worth setting up once a project stops being a weekend script — something that will keep evolving and maturing. In practice, most APIs, frontends, and long-running services qualify for versioning, while CLI tools that just live as cron jobs, even long-lived ones, typically don’t (for me, but your mileage may vary).

Two signals tend to arrive together. Once a codebase spans more than a few days of work, your memory of how it behaves drifts from how it actually behaves — that’s when tests stop being optional. And once you’re testing it, you’re also about to get tired of deploying it by hand. That’s the moment to automate.

Manual deployments are a good thing to do at first — it’s how you learn the startup quirks before you write the automation script. But once you do automate, you need a way to verify the deployment is live, and a version number is an easy way to check. This lives in the logs, on a health endpoint, or in a response header. A tag-based versioning pipeline stamps the version number to the code itself and makes the number you see trustworthy; the staging/production split is what makes it safe to automate deployment to staging and then a simple promotion to production when you are ready. Skip either half and you’re back to deploys you have to watch by hand.

The invariants

Five rules. If a repo can’t satisfy one, that’s worth writing down as a deliberate exception rather than letting it drift silently.

# Invariant
1 The version exists only as a git tag (vMAJOR.MINOR.PATCH) — never a string committed to source.
2 Exactly one tag per commit on main, created by CI, never by a human.
3 The bump level is a property of the pull request, declared in its title.
4 A tag never names a commit that failed CI.
5 main is reached only by a squash merge of a green PR.

Language variation: Python repos set dynamic = ["version"] in pyproject.toml; TypeScript/Node repos leave package.json’s version pinned at "0.0.0" and never touch it by hand. Either way, nothing in source states the real version — the mechanics for reading the tag into the built artifact are covered later, in Stamping the version into the artifact.

Computing the bump correctly

The bump level for a release is a property of the pull request, declared with a marker in its title:

Marker in the PR title Bump v1.4.2
[major] major v2.0.0
[minor] minor v1.5.0
(neither) patch v1.4.3

The mechanism that reads and interprets this marker is where it’s easy for things to go wrong. GitHub’s squash-merge commit message is not reliably the PR title:

  • A PR with two or more commits gets a squash subject that defaults to the PR title. The marker survives.
  • A PR with exactly one commit gets a squash subject that defaults to that single commit’s own message — the PR title never enters into it. A [minor] sitting only in the title is silently dropped, and the release ships as a patch.

Since many PRs can be a single commit, reading git log -1 --pretty=%s looks correct just often enough to pass review. It isn’t. Resolve the real title from the API instead, using the PR number GitHub always appends to the squash subject as (#N):

Terminal window
msg=$(git log -1 --pretty=%s)
pr_number=$(echo "$msg" | grep -oP '(?<=\(#)[0-9]+(?=\)\s*$)' || true)
if [ -n "$pr_number" ]; then
pr_title=$(gh pr view "$pr_number" --json title -q .title 2>/dev/null || true)
[ -n "$pr_title" ] && msg="$pr_title"
fi

The commit subject stays as the fallback for a direct push to main, which has no PR to look up.

The reference workflow

The full file, with two details easy to leave out and both load-bearing:

.github/workflows/version-bump.yml
name: Version Bump
on:
push:
branches: [main]
# Serialize: two merges seconds apart would otherwise both read the same
# "latest tag" and compute the same next version. cancel-in-progress: false
# is not optional — cancelling a version bump loses a release, not just
# delays it.
concurrency:
group: version-bump-main
cancel-in-progress: false
jobs:
tag:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read # needed to resolve the source PR's real title
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # REQUIRED — a shallow clone has no tags, and
# every run would compute v0.0.1
- name: Compute next tag
id: next
env:
GH_TOKEN: ${{ github.token }}
run: |
latest=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
latest=${latest:-v0.0.0}
IFS='.' read -r major minor patch <<< "${latest#v}"
msg=$(git log -1 --pretty=%s)
pr_number=$(echo "$msg" | grep -oP '(?<=\(#)[0-9]+(?=\)\s*$)' || true)
if [ -n "$pr_number" ]; then
pr_title=$(gh pr view "$pr_number" --json title -q .title 2>/dev/null || true)
[ -n "$pr_title" ] && msg="$pr_title"
fi
echo "Bump source: $msg"
if echo "$msg" | grep -qiE '\[major\]'; then
next="$((major + 1)).0.0"
elif echo "$msg" | grep -qiE '\[minor\]'; then
next="${major}.$((minor + 1)).0"
else
next="${major}.${minor}.$((patch + 1))"
fi
echo "Next version: v$next"
echo "tag=v$next" >> "$GITHUB_OUTPUT"
- name: Push tag
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "${{ steps.next.outputs.tag }}"
git push origin "${{ steps.next.outputs.tag }}"

fetch-depth: 0 and the concurrency block are the two lines most likely to get trimmed by someone copying this into a smaller workflow. Neither is decorative — drop the first and every run computes v0.0.1; drop the second and two merges seconds apart can compute the same next version twice. Always echo the bump source and the computed version, too — it’s the only way to diagnose a wrong bump after the fact instead of guessing at it.

Never tagging a red commit

Two valid designs. Pick deliberately, and write the choice down somewhere — don’t let it stay implicit.

A. on: push + a required status check (default choice)

A commit can’t reach main unless its PR passed test, so every push to main is already green by the time this workflow sees it. Tests run once, on the PR.

This is only safe if the required check genuinely exists on the repo — verify it, don’t assume it:

Terminal window
gh api "/repos/<org>/<repo>/rules/branches/main" \
| jq -e 'any(.[]; .type=="required_status_checks"
and any(.parameters.required_status_checks[]?; .context=="test"))' \
>/dev/null && echo "protected" || echo "NOT PROTECTED — do not use on: push"

B. on: workflow_run gated on the test workflow (fallback)

Unconditionally safe, at the cost of running the suite a second time after merge:

on:
workflow_run:
workflows: ["Tests"]
types: [completed]
branches: [main]
jobs:
tag:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }} # the commit CI passed

ref: head_sha matters here — without it you check out the branch tip, which may have moved on since the run you’re gating on. For example: a second, unrelated PR merges in the few minutes between the Tests run finishing and this job actually executing — main has already advanced to a commit this particular gate never saw pass, and checking out the tip would tag and deploy it anyway.

Prefer A. Reach for B only when you don’t control the repo’s settings closely enough to guarantee the required check actually exists.

Chaining the deploy off the tag

A tag pushed by the workflow’s own GITHUB_TOKEN does not trigger another workflow — GitHub suppresses events created by the default token, specifically to prevent infinite loops. on: push: tags: ['v*'] in a deploy workflow will simply never fire.

Chain off the tagging workflow instead:

on:
workflow_run:
workflows: ["Version Bump"]
types: [completed]
branches: [main]
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'

The if: guard is mandatory, not optional — types: [completed] fires on failure too, and without the guard a failed version bump would still trigger a deploy of whatever was last successfully tagged.

Worth being precise about what this guard actually proves: that Version Bump succeeded, not that tests passed. Version Bump happily tags a red commit if nothing upstream stopped it — that guarantee comes from the previous section, not this one. This step only trusts what already reached it.

One more thing worth being explicit about: because the tag points at one exact, immutable commit, any version you’ve ever shipped is reproducible on demand — checking it out gives you byte-identical code to what actually deployed. That’s what makes rollback, or answering “what did production run last Tuesday,” a git checkout away rather than a guess.

The staging/production split

From here, the examples use control-api — an IoT device monitoring and control platform — as the running name, purely for consistency across snippets.

Staging deploys automatically, chained off the same Version Bump success event as the previous section:

.github/workflows/deploy-staging.yml
name: Deploy (staging)
on:
workflow_run:
workflows: ["Version Bump"]
types: [completed]
branches: [main]
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: [self-hosted, staging, control-api]
environment: staging
steps:
- name: Deploy
run: ./deploy.sh "${{ github.event.workflow_run.head_sha }}"

Production does not auto-deploy. It’s workflow_dispatch-only, and the operator names the exact tag being promoted — nothing reaches production without someone deliberately choosing to send it there:

.github/workflows/deploy-production.yml
name: Deploy (production)
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to promote to production (e.g. v1.4.2)'
required: true
type: string
jobs:
deploy:
runs-on: [self-hosted, production, control-api]
environment: production
steps:
- name: Verify tag exists
run: |
git ls-remote --tags origin "${{ inputs.tag }}" | grep -q . || {
echo "::error::Tag ${{ inputs.tag }} not found"; exit 1; }
- name: Deploy
run: ./deploy.sh "${{ inputs.tag }}"

There’s a reason to prefer this beyond the plan-tier limitation below: nothing beats a human actually looking at staging working before that same tag reaches production. Automating that step away removes the one check most likely to catch a problem before real users do.

Why manual-dispatch, not GitHub’s built-in reviewer gate: GitHub Environments support a “required reviewers” protection rule — a human approves before a job in that environment runs — which looks like the obvious way to gate production. On a private repo, that rule needs a paid Team or Enterprise plan. That’s confirmed by the API rejecting the request outright, not assumed from a pricing page. workflow_dispatch-only is the free equivalent: nothing reaches production except someone deliberately triggering it and naming the exact tag. If the plan ever changes, swap in the reviewer gate — until then, this is the mechanism that’s actually available, not a compromise standing in for one that isn’t.

Environment-scoped secrets

The rule underneath this: production config is never committed, but neither is it hand-edited on the deploy host. A deploy job writes it fresh, immediately before the app restarts, sourced from GitHub Environment secrets and variables.

Staging and production are already separate GitHub Environments (environment: staging / environment: production in the workflows above), so they can each carry their own secrets and variables — visible only to jobs that declare that environment. Repo-level secrets stay visible everywhere regardless; an environment-scoped secret with the same name takes precedence.

Classify by sensitivity, not by where the value already happens to live:

  • Secrets — anything bad to leak: connection strings, API keys, tokens, passwords. Masked in logs, and write-only — there’s no API to read a secret’s value back, only to overwrite or delete it.
  • Variables — everything else: hostnames, ports, log levels, feature flags. Plaintext, readable back with gh variable get.

When genuinely unsure, classify as a secret. The cost of an over-cautious secret is a masked log line; the cost of an under-cautious variable is a leaked credential.

Terminal window
# create the environment (idempotent)
gh api -X PUT repos/<org>/<repo>/environments/production
# secrets and variables, scoped to one environment
gh secret set DATABASE_URL --repo <org>/<repo> --env production --body "<value>"
gh variable set LOG_LEVEL --repo <org>/<repo> --env production --body "info"

Consumption shape depends on how the app actually runs. Docker Compose reads a generated file via env_file: .env; a bare-metal/systemd service either sets EnvironmentFile=/apps/<repo>/.env on the unit, or relies on the app’s own config loader (e.g. pydantic-settings) to read a dotenv file directly. Check what’s already there before assuming you need to add anything new — the missing piece is usually just a deploy step that writes the file fresh, not a new way of reading it.

A migration check, not blind faith

Before restarting the service, check whether a migration is actually needed, in a disposable container, before touching the live one:

Terminal window
current=$(alembic current 2>/dev/null | awk '{print $1}')
heads=$(alembic heads 2>/dev/null | awk '{print $1}')
if [ "$current" != "$heads" ]; then
echo "Migration needed: $current -> $heads"
alembic upgrade head
else
echo "Already at head ($current) — nothing to do"
fi

Why bother, when alembic upgrade head is idempotent anyway? Running it unconditionally on every deploy would work fine functionally — an already-migrated database just no-ops. But that means shelling into a throwaway container and running a migration command on every single release, whether or not anything needed migrating, instead of a fast, boring “already at head, nothing to do” in the common case. This isn’t a correctness fix, it’s a taste call, and it’s worth making anyway: a clean log line has real value the one time something does go wrong and you need to actually trust what the log is telling you, instead of output that looks identical whether a migration ran or not.

Running this against production is always going to come with a little anxiety, and that’s exactly why it should run against staging first. Not just to confirm the upgrade actually works — doing it in staging first is what builds the trust to run it against production at all, and it’s where you learn how to unpick a migration that’s gone wrong, while it’s still staging and not production.

Retiring scaffolding once it’s earned out

It’s reasonable for the first version of this pipeline to include a backstop: an hourly cron job that redeploys the latest tag regardless, in case the real trigger (the workflow_run chain above) ever fails silently. A dead-man’s switch, not the primary mechanism.

That can look like a lazy or clumsy choice next to a proper event-driven trigger. It isn’t, as long as it stays scoped as a backstop and not the mechanism doing the real work. It earns its keep especially in pipelines that depend on someone else’s codebase — a public repo you’re building into a production system of your own, say — where the trigger crossing that repo boundary has more ways to fail silently than a same-repo workflow_run chain ever does.

Once that primary trigger has actually run enough times, cleanly, to trust — take the backstop back out. Not because it’s doing any harm sitting there. It’s scaffolding for a level of uncertainty that no longer exists, and leaving it in place after it’s earned out just adds a second, quieter deploy path that nobody watches as closely as the one they actually built. Removing it is itself a small piece of evidence the pattern has landed, not just shipped.

Stamping the version into the artifact

The rule so far has been “no version string in source.” This is the one section where that becomes explicitly language-specific — the git tag is the version everywhere, but how a build reads it into the artifact differs.

Python (hatch-vcs)

pyproject.toml
[project]
name = "control-api"
dynamic = ["version"]
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"

Read it back at runtime:

from importlib.metadata import PackageNotFoundError, version
def get_version() -> str:
"""Return the installed package version, or a dev placeholder."""
try:
return version("control-api")
except PackageNotFoundError:
return "0.0.0+unknown"

Docker (any language)

Never ship .git in the image. .dockerignore omits it along with everything else untracked, so hatch-vcs sees what looks like a dirty tree and stamps a …dev0+g<sha> version — even on a clean release tag. Inject the real version explicitly instead:

ARG SETUPTOOLS_SCM_PRETEND_VERSION
ENV SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION}
Terminal window
# VERSION must be the bare number — no leading "v"
docker build --build-arg SETUPTOOLS_SCM_PRETEND_VERSION=${GITHUB_REF_NAME#v} .

Use the generic SETUPTOOLS_SCM_PRETEND_VERSION name, not a package-scoped _FOR_<NAME> variant — the scoped form silently does nothing if the name doesn’t match exactly, and the build still succeeds, just with the wrong version baked in.

TypeScript / Node

package.json’s version field stays pinned at "0.0.0" in source. Stamp the real version in at build time instead:

Terminal window
VERSION="${GITHUB_REF_NAME#v}"
jq --arg v "$VERSION" '.version = $v' package.json > tmp && mv tmp package.json

Don’t use npm version for this — it commits the bump, undoing the one rule this whole recipe is built on: no version string lives in source.

Checklist

Versioning

  • The version exists only as a git tag — nothing hardcoded in source
  • The bump workflow resolves the PR title via the API, never the commit subject
  • concurrency: {group: version-bump-main, cancel-in-progress: false} is set
  • fetch-depth: 0 is set on the checkout
  • The trigger is on: push backed by a verified required status check, or workflow_run gated on the test workflow’s conclusion
  • The bump source and computed version are both echoed in the log

Deploy

  • The deploy workflow chains off Version Bump via workflow_run, guarded by conclusion == 'success'
  • Staging auto-deploys; production is workflow_dispatch-only, naming a specific tag
  • Staging and production are separate GitHub Environments, with secrets and variables classified and scoped correctly
  • The migration step checks current against heads before running anything
  • Any temporary backstop (a cron dead-man’s-switch, say) is scoped as temporary — and actually removed once it’s earned out

Artifact

  • The version reads into the built artifact correctly for the stack in use (hatch-vcs, a Docker build-arg, or a build-time package.json stamp)

None of the pieces here are exotic on their own — a workflow that reads a PR title correctly, a second environment that only deploys on purpose, a migration check that mostly just says “nothing to do.” What they add up to is a pipeline you can trust enough to stop watching.

Pick one repo you already deploy by hand. Run the checklist above against it, right now, and see how many boxes are already checked before you assume you’re starting from zero.