claude-home/development/tag-triggered-release-deploy.md
Cal Corum b1fed02219
All checks were successful
Reindex Knowledge Base / reindex (push) Successful in 3s
docs: sync KB — tag-triggered-release-deploy.md,release-2026.3.20.md claude-code-config.md
2026-03-20 14:00:43 -05:00

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
docker
gitea
deployment
ci
calver
scripts
bash

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

  1. Verifies you're on main and in sync with origin
  2. Auto-generates next CalVer build number from existing tags (or uses the one you passed)
  3. Validates version format and checks for duplicate tags
  4. Shows commits since last tag for review
  5. 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

  1. Shows current branch, commit, and target
  2. Saves previous image digest (for rollback)
  3. Pulls latest image via docker compose pull
  4. Restarts container via docker compose up -d
  5. Waits 5 seconds, shows container status and logs
  6. Prints rollback command if image changed

Key details

  • Uses SSH alias (ssh akamai) — never hardcode ssh -i paths
  • 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:

  1. Create a hotfix/ branch from main
  2. Fix, test, push, open PR
  3. Merge PR to main
  4. 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 tag history)
  • Easy rollbacks (docker pull myorg/myapp:2026.3.9)
  • Scriptable (release.sh + deploy.sh)
  • Decoupled merge and release cycles