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