feat: add dynamic versioning and automated deployment with rollback capability

- Implement setuptools-scm for dynamic version management from git tags
- Refactor CI/CD into separate build and deploy jobs with artifact sharing
- Add versioned releases with timestamp-based deployment directories
- Implement health checks and automatic rollback on deployment failure
- Extract deployment logic into reusable shell scripts
- Add Docker layer caching to speed up builds
- Include version info in Django context and build args
This commit is contained in:
badblocks 2025-06-06 14:38:23 -07:00
parent 46619bd5e1
commit f20c4f9474
No known key found for this signature in database
14 changed files with 719 additions and 233 deletions

View file

@ -8,37 +8,37 @@ on:
branches: [main]
jobs:
build-deploy:
# Job 1: Build the Docker image
build:
runs-on: ubuntu-latest
outputs:
repo: ${{ steps.meta.outputs.REPO }}
repo-name: ${{ steps.meta.outputs.REPO_NAME_ONLY }}
repo-path: ${{ steps.meta.outputs.REPO_PROJECT_PATH }}
image-tar: ${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
tags: ${{ steps.generated_docker_tags.outputs.tag }}
prod: ${{ steps.env.outputs.prod }}
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Ensure scripts are executable
run: chmod +x scripts/*.sh
- name: Get full and partial repository name
id: meta
run: |
echo "GITHUB_REPOSITORY: ${{ github.repository }}"
if [[ "${{ github.repository }}" == *".git" ]]; then
if [[ "${{ github.repository }}" == "https://"* ]]; then
echo "GITHUB_REPOSITORY ends in .git and is a URL"
REPO=$(echo "${{ github.repository }}" | sed 's/\.git$//' | cut -d'/' -f4-5 | sed 's/[^a-zA-Z0-9\/-]/-/g')
else
echo "GITHUB_REPOSITORY ends in .git and is not a URL"
REPO=$(echo "${{ github.repository }}" | sed 's/\.git$//' | sed 's/[^a-zA-Z0-9\/-]/-/g')
fi
else
echo "GITHUB_REPOSITORY is not a URL"
REPO=$(echo "${{ github.repository }}" | sed 's/[^a-zA-Z0-9\/-]/-/g')
fi
# Parse repository name and set outputs
eval "$(./scripts/parse-repository-name.sh '${{ github.repository }}')"
echo "REPO=$REPO" >> $GITHUB_OUTPUT
REPO_NAME_ONLY=$(echo "$REPO" | cut -d'/' -f2)
echo "REPO_NAME_ONLY=$REPO_NAME_ONLY" >> $GITHUB_OUTPUT
REPO_PROJECT_PATH=/srv/$(echo "$REPO_NAME_ONLY")
echo "REPO_PROJECT_PATH=$REPO_PROJECT_PATH" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set PROD environment variable
id: env
run: |
echo "✅ Exit script on any error"
set -eu -o pipefail
@ -57,144 +57,99 @@ jobs:
echo "🖊️ Writing determined values to GITHUB_ENV:"
echo "PROD=${prod_value}" >> $GITHUB_ENV
echo "PROD=${prod_value} -> GITHUB_ENV"
echo "prod=${prod_value}" >> $GITHUB_OUTPUT
- name: Generate tags
id: generated_docker_tags
run: |
echo "✅ Exit script on any error"
set -eu -o pipefail
# echo current shell
echo "Current shell: $SHELL"
IMAGE_BASE_NAME="${{ steps.meta.outputs.REPO }}"
GIT_SHA="${{ github.sha }}"
GIT_REF="${{ github.ref }}"
echo "Inputs for tagging:"
echo "IMAGE_BASE_NAME: $IMAGE_BASE_NAME"
echo "GIT_SHA: $GIT_SHA"
echo "GIT_REF: $GIT_REF"
echo "PROD status for tagging: ${PROD}"
TAG_LIST=()
VERSION_TAG_LIST=()
if [[ -n "$GIT_SHA" ]]; then
SHORT_SHA=$(echo "$GIT_SHA" | cut -c1-7)
TAG_LIST+=("${IMAGE_BASE_NAME}:sha-${SHORT_SHA}")
TAG_LIST+=("${IMAGE_BASE_NAME}:sha-${GIT_SHA}")
else
echo "🔴 No Git SHA found, cannot generate tags. Aborting."
exit 1
fi
GIT_TAG_VERSION=""
# Extract version only if GIT_REF is a tag like refs/tags/vX.Y.Z or refs/tags/vX.Y.Z-prerelease
if [[ "$GIT_REF" == refs/tags/v* ]]; then
GIT_TAG_VERSION=$(echo "$GIT_REF" | sed 's%refs/tags/v%%' | sed 's%-prerelease$%%')
if [[ "$GIT_TAG_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Detected Git tag: v$GIT_TAG_VERSION"
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
echo "Parsed version: Major=$MAJOR, Minor=$MINOR, Patch=$PATCH from v$GIT_TAG_VERSION"
if [ "$MAJOR" -gt 0 ]; then
VERSION_TAG_LIST+=("${IMAGE_BASE_NAME}:v${MAJOR}")
else
echo " Major version is 0 (v$GIT_TAG_VERSION). Skipping MAJOR-only tag v0. Please reference by MAJOR.MINOR or MAJOR.MINOR.PATCH."
fi
VERSION_TAG_LIST+=("${IMAGE_BASE_NAME}:v${MAJOR}.${MINOR}")
VERSION_TAG_LIST+=("${IMAGE_BASE_NAME}:v${MAJOR}.${MINOR}.${PATCH}")
else
echo "⚠️ Git tag 'v$GIT_TAG_VERSION' is not a valid semantic version (x.y.z) but should be. Aborting."
exit 1
fi
fi
if [ "$PROD" = "true" ]; then
echo "📦 Generating PROD tags."
TAG_LIST+=("${IMAGE_BASE_NAME}:stable")
TAG_LIST+=("${IMAGE_BASE_NAME}:latest")
if [ ${#VERSION_TAG_LIST[@]} -gt 0 ]; then
TAG_LIST+=("${VERSION_TAG_LIST[@]}")
else
echo "🔴 PROD mode is true, but Git ref ($GIT_REF) is not a valid version tag. This is unexpected, aborting."
exit 1
fi
else # Non-PROD
echo "🔨 Generating STAGING tags."
TAG_LIST+=("${IMAGE_BASE_NAME}:staging")
TAG_LIST+=("${IMAGE_BASE_NAME}:latest-staging")
if [ ${#VERSION_TAG_LIST[@]} -gt 0 ]; then
echo "🔨 This is also a prerelease version, generating version tags with '-prerelease' suffix."
VERSION_TAG_LIST=("${VERSION_TAG_LIST[@]/%/-prerelease}")
TAG_LIST+=("${VERSION_TAG_LIST[@]}")
else
echo " Git ref ($GIT_REF) is not a valid version tag. Skipping versioned -prerelease tag generation."
fi
fi
# Use the script to generate tags
TAG_LIST=$(./scripts/generate-docker-tags.sh \
"${{ steps.meta.outputs.REPO }}" \
"${{ github.sha }}" \
"${{ github.ref }}" \
"$PROD")
echo "Final list of generated tags:"
printf "%s\n" "${TAG_LIST[@]}"
if [[ -z "$GIT_SHA" || ${#TAG_LIST[@]} -lt 4 ]]; then
echo "⚠️ No tags (or too few) were generated based on the logic. Need at least 4 tags: Git commit short and full length SHA tags, a latest/latest-staging tag, and a stable/staging tag. This is unexpected, aborting."
echo "$TAG_LIST"
TAG_COUNT=$(echo "$TAG_LIST" | wc -l)
if [[ -z "${{ github.sha }}" || $TAG_COUNT -lt 4 ]]; then
echo "⚠️ No tags (or too few) were generated based on the logic. Need at least 4 tags. Generated: $TAG_COUNT"
exit 1
fi
# Output the tags for the docker build action (output name is 'tag')
# Output the tags for the docker build action
{
echo "tag<<EOF"
printf "%s\n" "${TAG_LIST[@]}"
echo "$TAG_LIST"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Run prebuild tasks
run: ./scripts/prebuild.sh
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Extract version for Docker build
id: extract_version
run: |
echo "🔄 Chdir to src/pkmntrade_club/theme/static_src"
cd src/pkmntrade_club/theme/static_src
echo "📦 Install npm dependencies"
npm install .
echo "🔨 Build the tailwind theme css"
npm run build
# - name: Cache Docker layers
# uses: actions/cache@v4
# with:
# path: ${{ runner.temp }}/.cache
# key: ${{ runner.os }}-${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}-${{ github.sha }}
# restore-keys: |
# ${{ runner.os }}-${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}-
# - name: Inject buildx-cache
# uses: reproducible-containers/buildkit-cache-dance@4b2444fec0c0fb9dbf175a96c094720a692ef810 # v2.1.4
# with:
# cache-source: ${{ runner.temp }}/.cache/buildx-cache
pip install setuptools-scm
VERSION=$(python -c "from setuptools_scm import get_version; print(get_version())")
echo "VERSION=${VERSION}" >> $GITHUB_ENV
- name: Build container
uses: docker/build-push-action@v6
with:
outputs: type=docker,dest=${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
tags: ${{ steps.generated_docker_tags.outputs.tag }}
build-args: CACHE_DIR=${{ runner.temp }}/.cache/dockerfile-cache
build-args: |
VERSION=${{ env.VERSION }}
context: .
#cache-from: type=local,src=${{ runner.temp }}/.cache/buildx-cache
#cache-to: type=local,src=${{ runner.temp }}/.cache/buildx-cache-new,mode=max
# - name: Rotate cache # along with cache-from & cache-to: prevents cache from growing indefinitely
# run: |
# rm -rf ${{ runner.temp }}/.cache/buildx-cache
# mv ${{ runner.temp }}/.cache/buildx-cache-new ${{ runner.temp }}/.cache/buildx-cache
# - name: Upload container as artifact
# uses: actions/upload-artifact@v4
# with:
# name: ${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
# path: ${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
# if-no-files-found: error
# compression-level: 0
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Rotate cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: Upload container as artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: ${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
if-no-files-found: error
retention-days: 1
# Job 2: Deploy (only runs on main branch or tags)
deploy:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
environment: ${{ needs.build.outputs.prod == 'true' && 'production' || 'staging' }}
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Ensure scripts are executable
run: chmod +x scripts/*.sh
- name: Download container artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: ${{ runner.temp }}
- name: Get Deploy Secrets
uses: bitwarden/sm-action@v2
with:
@ -205,6 +160,7 @@ jobs:
9aefe34e-c2cf-442e-973c-b2dd0032b6cf > ENV_FILE_BASE64
d3bb47f8-bfc0-4a61-9cee-b2df0147a02a > CF_PEM_CERT
5f658ddf-aadd-4464-b501-b2df0147c338 > CF_PEM_CA
- name: Set up SSH
run: |
mkdir -p $HOME/.ssh
@ -224,105 +180,40 @@ jobs:
ControlPath $HOME/.ssh/control-%C
ControlPersist yes
END
- name: Run Deploy Script
- name: Deploy to Server
env:
DOCKER_HOST: ssh://deploy
REPO_PROJECT_PATH: ${{ needs.build.outputs.repo-path }}
REPO_NAME_ONLY: ${{ needs.build.outputs.repo-name }}
IMAGE_TAR: ${{ runner.temp }}/${{ needs.build.outputs.image-tar }}
IS_PROD: ${{ needs.build.outputs.prod }}
run: |
echo "✅ Exit script on any error"
set -eu -o pipefail
echo "⚙️ Set docker host to ssh://deploy so that all docker commands are run on the remote server"
export DOCKER_HOST=ssh://deploy
echo "🚀 Enable and start docker service"
ssh deploy "sudo systemctl enable --now docker.service"
echo "💾 Load the new docker image (${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar)"
docker load -i "${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar"
echo "🧹 Remove the docker image artifact"
rm "${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar"
echo "💾 Copy new files to server"
ssh deploy "mkdir -p ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new"
scp -pr ./server/* deploy:${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/
echo "📝 Create new .env file"
printf "%s" "${ENV_FILE_BASE64}" | base64 -d | ssh deploy "cat > ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/.env && chmod 600 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/.env"
echo "🔑 Set up certs"
ssh deploy "mkdir -p ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs && chmod 550 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs && chown 99:root ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs"
printf "%s" "$CF_PEM_CERT" | ssh deploy "cat > ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs/crt.pem && chmod 440 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs/crt.pem && chown 99:root ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs/crt.pem"
printf "%s" "$CF_PEM_CA" | ssh deploy "cat > ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs/ca.pem && chmod 440 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs/ca.pem && chown 99:root ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/certs/ca.pem"
ssh -T deploy <<EOF
set -eu -o pipefail
cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}}
if [[ -f "docker-compose_web.yml" || -f "docker-compose_staging.yml" ]]; then
# if we have an existing deployment
echo "🛑 Stop and remove old containers"
docker compose -f docker-compose_web.yml down
if [ "${PROD}" = false ]; then
docker compose -f docker-compose_staging.yml down
fi
if [ -f "docker-compose_core.yml" ] && ! diff -q docker-compose_core.yml new/docker-compose_core.yml; then
echo "⚠️ docker-compose_core.yml has changed, stopping and removing old core containers"
docker compose -f docker-compose_core.yml down
else
echo "⚠️ No changes to docker-compose_core.yml, but reloading due to volume mounts being tied to old directories"
docker compose -f docker-compose_core.yml down
fi
echo "🔄 Backup old files (exclude new and backup directories)"
mkdir -p ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/backup
find ${{ steps.meta.outputs.REPO_PROJECT_PATH }} -mindepth 1 -maxdepth 1 -path ${{ steps.meta.outputs.REPO_PROJECT_PATH }}/new -prune -o -path ${{ steps.meta.outputs.REPO_PROJECT_PATH }}/backup -prune -o -exec mv '{}' ${{ steps.meta.outputs.REPO_PROJECT_PATH }}/new/backup/ ';'
fi
EOF
echo "🔄 Move all new files into place"
ssh deploy "cd / && mv ${{ steps.meta.outputs.REPO_PROJECT_PATH}} /tmp/"
ssh deploy "cd / && mv /tmp/${{ steps.meta.outputs.REPO_NAME_ONLY}}/new ${{ steps.meta.outputs.REPO_PROJECT_PATH}}"
./scripts/deploy-to-server.sh
echo "🔄 Remove old files/directories if they exist"
ssh deploy "rm -rf /tmp/${{ steps.meta.outputs.REPO_NAME_ONLY}} || true"
echo "🚀 Start the new containers"
if [ "${PROD}" = true ]; then
ssh deploy "cd ${{ steps.meta.outputs.REPO_PROJECT_PATH }} && docker compose -f docker-compose_core.yml -f docker-compose_web.yml up -d --no-build"
- name: Health Check and Rollback
run: |
# Determine the correct URL based on environment
if [ "${{ needs.build.outputs.prod }}" = "true" ]; then
# Ensure PRODUCTION_DOMAIN is set
if [ -z "${{ vars.PRODUCTION_DOMAIN }}" ]; then
echo "Error: PRODUCTION_DOMAIN is not set"
exit 1
fi
HEALTH_CHECK_URL="https://${{ vars.PRODUCTION_DOMAIN }}/health/"
else
ssh deploy "cd ${{ steps.meta.outputs.REPO_PROJECT_PATH }} && docker compose -f docker-compose_core.yml -f docker-compose_web.yml -f docker-compose_staging.yml up -d --no-build"
# Ensure STAGING_DOMAIN is set
if [ -z "${{ vars.STAGING_DOMAIN }}" ]; then
echo "Error: STAGING_DOMAIN is not set"
exit 1
fi
HEALTH_CHECK_URL="https://${{ vars.STAGING_DOMAIN }}/health/"
fi
# echo "🚀 Start the new containers, zero-downtime"
# if [ "${PROD}" = true ]; then
# ssh deploy <<EOF
# cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}}
# old_container_id=$(docker compose -f docker-compose_web.yml ps -f name=web -q | tail -n1)
# docker compose -f docker-compose_web.yml up -d --no-build --no-recreate
# new_container_id=$(docker compose -f docker-compose_web.yml ps -f name=web -q | head -n1)
# # not needed, but might be useful at some point
# #new_container_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $new_container_id)
# #new_container_name=$(docker inspect -f '{{.Name}}' $new_container_id | cut -c2-)
# sleep 100 # change to wait for healthcheck in the future
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
# docker stop $old_container_id
# docker rm $old_container_id
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
# EOF
# else
# ssh deploy <<EOF
# cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}}
# old_container_id=$(docker compose -f docker-compose_staging.yml ps -f name=web-staging -q | tail -n1)
# docker compose -f docker-compose_staging.yml up -d --no-build --no-recreate
# new_container_id=$(docker compose -f docker-compose_staging.yml ps -f name=web-staging -q | head -n1)
# # not needed, but might be useful at some point
# #new_container_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $new_container_id)
# #new_container_name=$(docker inspect -f '{{.Name}}' $new_container_id | cut -c2-)
# sleep 100 # change to wait for healthcheck in the future
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
# docker stop $old_container_id
# docker rm $old_container_id
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
# EOF
# fi
echo "🧹 Prune all unused images"
docker system prune -f
# Copy script to remote and execute
scp scripts/health-check-and-rollback.sh deploy:/tmp/
ssh deploy "chmod +x /tmp/health-check-and-rollback.sh"
ssh deploy "/tmp/health-check-and-rollback.sh '${{ needs.build.outputs.repo-path }}' '${{ needs.build.outputs.prod }}' '$HEALTH_CHECK_URL' 30"
ssh deploy "rm -f /tmp/health-check-and-rollback.sh"