---
title: "Tag-Triggered Release and Deploy Guide"
description: "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."
type: guide
domain: development
tags: [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.

```bash
# 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

```bash
#!/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.

```yaml
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.

```bash
# 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

```yaml
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:

```bash
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
