6.9 KiB
| title | description | type | domain | tags | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Tag-Triggered Release and Deploy Guide | CalVer tag-triggered CI/CD workflow: push a git tag to build Docker images, then deploy with a script. Reference implementation from Major Domo Discord bot. | guide | development |
|
Tag-Triggered Release and Deploy Guide
Standard release workflow for Dockerized applications using CalVer git tags to trigger CI builds and a deploy script for production rollout. Decouples code merging from releasing — merges to main are free, releases are intentional.
Overview
merge PR to main → code lands, nothing builds
.scripts/release.sh → creates CalVer tag, pushes to trigger CI
CI builds → Docker image tagged with version + "production"
.scripts/deploy.sh → pulls image on production host, restarts container
CalVer Format
YYYY.M.BUILD — year, month (no leading zero), incrementing build number within that month.
Examples: 2026.3.10, 2026.3.11, 2026.4.1
Release Script
.scripts/release.sh — creates a git tag and pushes it to trigger CI.
# Auto-generate next version
.scripts/release.sh
# Explicit version
.scripts/release.sh 2026.3.11
# Skip confirmation
.scripts/release.sh -y
# Both
.scripts/release.sh 2026.3.11 -y
What it does
- Verifies you're on
mainand in sync with origin - Auto-generates next CalVer build number from existing tags (or uses the one you passed)
- Validates version format and checks for duplicate tags
- Shows commits since last tag for review
- Confirms, then tags and pushes
Reference implementation
#!/usr/bin/env bash
set -euo pipefail
SKIP_CONFIRM=false
VERSION=""
for arg in "$@"; do
case "$arg" in
-y) SKIP_CONFIRM=true ;;
*) VERSION="$arg" ;;
esac
done
# Ensure we're on main and up to date
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" != "main" ]]; then
echo "ERROR: Must be on main branch (currently on ${BRANCH})"
exit 1
fi
git fetch origin main --tags --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [[ "$LOCAL" != "$REMOTE" ]]; then
echo "ERROR: Local main is not up to date with origin. Run: git pull"
exit 1
fi
# Determine version
YEAR=$(date +%Y)
MONTH=$(date +%-m)
if [[ -z "$VERSION" ]]; then
LAST_BUILD=$(git tag --list "${YEAR}.${MONTH}.*" --sort=-v:refname | head -1 | awk -F. '{print $3}')
NEXT_BUILD=$(( ${LAST_BUILD:-0} + 1 ))
VERSION="${YEAR}.${MONTH}.${NEXT_BUILD}"
fi
# Validate
if [[ ! "$VERSION" =~ ^20[0-9]{2}\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: Invalid version format '${VERSION}'. Expected YYYY.M.BUILD"
exit 1
fi
if git rev-parse "refs/tags/${VERSION}" &>/dev/null; then
echo "ERROR: Tag ${VERSION} already exists"
exit 1
fi
# Show what's being released
LAST_TAG=$(git tag --sort=-v:refname | head -1)
echo "Version: ${VERSION}"
echo "Previous: ${LAST_TAG:-none}"
echo "Commit: $(git log -1 --format='%h %s')"
if [[ -n "$LAST_TAG" ]]; then
echo "Changes since ${LAST_TAG}:"
git log "${LAST_TAG}..HEAD" --oneline --no-merges
fi
# Confirm
if [[ "$SKIP_CONFIRM" != true ]]; then
read -rp "Create tag ${VERSION} and trigger release? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi
# Tag and push
git tag "$VERSION"
git push origin tag "$VERSION"
echo "==> Tag ${VERSION} pushed. CI will build the image."
echo "Deploy with: .scripts/deploy.sh"
CI Workflow (Gitea Actions)
.gitea/workflows/docker-build.yml — triggered by tag push, builds and pushes Docker image.
name: Build Docker Image
on:
push:
tags:
- '20*' # matches CalVer tags like 2026.3.11
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
myorg/myapp:${{ steps.version.outputs.version }}
myorg/myapp:production
cache-from: type=registry,ref=myorg/myapp:buildcache
cache-to: type=registry,ref=myorg/myapp:buildcache,mode=max
Docker image tags
| Tag | Type | Purpose |
|---|---|---|
2026.3.10 |
Immutable | Pinpoints exact version, rollback target |
production |
Floating | Always the latest release, used in docker-compose |
Deploy Script
.scripts/deploy.sh — pulls the latest production image and restarts the container.
# Interactive (confirms before deploying)
.scripts/deploy.sh
# Non-interactive
.scripts/deploy.sh -y
What it does
- Shows current branch, commit, and target
- Saves previous image digest (for rollback)
- Pulls latest image via
docker compose pull - Restarts container via
docker compose up -d - Waits 5 seconds, shows container status and logs
- Prints rollback command if image changed
Key details
- Uses SSH alias (
ssh akamai) — never hardcodessh -ipaths - Image tag is
production(floating) — compose always pulls the latest release - Rollback uses the saved digest, not a version tag, so it's exact
Production docker-compose
services:
myapp:
image: myorg/myapp:production
restart: unless-stopped
volumes:
- ./credentials.json:/app/credentials.json:ro # secrets read-only
- ./storage:/app/storage:rw # state files writable
- ./logs:/app/logs
Hotfix Workflow
When production is broken and you need to fix fast:
- Create a
hotfix/branch frommain - Fix, test, push, open PR
- Merge PR to
main - Delete the current tag and re-release on the new HEAD:
git tag -d YYYY.M.BUILD
git push origin :refs/tags/YYYY.M.BUILD
.scripts/release.sh YYYY.M.BUILD -y
# wait for CI...
.scripts/deploy.sh -y
Re-using the same version number is fine for hotfixes — it keeps the version meaningful ("this is what's deployed") rather than burning a new number for a 1-line fix.
Why This Pattern
| Alternative | Downside |
|---|---|
| Branch-push trigger | Every merge = build = deploy pressure. Can't batch changes. |
next-release staging branch |
Extra ceremony, merge conflicts, easy to forget to promote |
workflow_dispatch (manual UI button) |
Less scriptable, no git tag trail |
| GitHub Releases UI | Heavier than needed for Docker-only deploys |
Tag-triggered releases give you:
- Clear audit trail (
git taghistory) - Easy rollbacks (
docker pull myorg/myapp:2026.3.9) - Scriptable (
release.sh+deploy.sh) - Decoupled merge and release cycles