From f20c4f94748b67f904cb2c587afd6fbcf6b53884 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Fri, 6 Jun 2025 14:38:23 -0700 Subject: [PATCH] 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 --- .github/workflows/build_deploy.yml | 351 ++++++------------ pyproject.toml | 8 +- scripts/deploy-to-server.sh | 124 +++++++ scripts/generate-docker-tags.sh | 49 +++ scripts/health-check-and-rollback.sh | 102 +++++ scripts/manage-releases.sh | 120 ++++++ scripts/parse-repository-name.sh | 36 ++ scripts/prepare-deployment.sh | 44 +++ scripts/retry.sh | 23 ++ src/pkmntrade_club/__init__.py | 5 + src/pkmntrade_club/_version.py | 61 +++ .../common/context_processors.py | 6 + src/pkmntrade_club/django_project/settings.py | 6 + uv.lock | 17 +- 14 files changed, 719 insertions(+), 233 deletions(-) create mode 100644 scripts/deploy-to-server.sh create mode 100644 scripts/generate-docker-tags.sh create mode 100644 scripts/health-check-and-rollback.sh create mode 100644 scripts/manage-releases.sh create mode 100644 scripts/parse-repository-name.sh create mode 100644 scripts/prepare-deployment.sh create mode 100644 scripts/retry.sh create mode 100644 src/pkmntrade_club/_version.py diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml index bf61f71..d9cddf8 100644 --- a/.github/workflows/build_deploy.yml +++ b/.github/workflows/build_deploy.yml @@ -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<> "$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 <=2.3", ] [project.scripts] @@ -79,3 +80,8 @@ Homepage = "https://pkmntrade.club" [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools_scm] +version_scheme = "no-guess-dev" +tag_regex = "^v(?P[0-9]+(?:\\.[0-9]+)*(?:-.*)?)" +fallback_version = "0.0.0+unknown" diff --git a/scripts/deploy-to-server.sh b/scripts/deploy-to-server.sh new file mode 100644 index 0000000..15fd44b --- /dev/null +++ b/scripts/deploy-to-server.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -euo pipefail + +# Main deployment script with versioned releases +# Usage: ./deploy-to-server.sh + +# Source retry function +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/retry.sh" + +# Required environment variables (should be set by GitHub Actions) +: "${DOCKER_HOST:?Error: DOCKER_HOST not set}" +: "${REPO_PROJECT_PATH:?Error: REPO_PROJECT_PATH not set}" +: "${REPO_NAME_ONLY:?Error: REPO_NAME_ONLY not set}" +: "${IMAGE_TAR:?Error: IMAGE_TAR not set}" +: "${ENV_FILE_BASE64:?Error: ENV_FILE_BASE64 not set}" +: "${CF_PEM_CERT:?Error: CF_PEM_CERT not set}" +: "${CF_PEM_CA:?Error: CF_PEM_CA not set}" +: "${IS_PROD:?Error: IS_PROD not set}" + +echo "βš™οΈ Docker host: $DOCKER_HOST" + +# Generate deployment timestamp +DEPLOYMENT_TIMESTAMP=$(date +%Y%m%d_%H%M%S) +RELEASES_PATH="${REPO_PROJECT_PATH}/releases" +NEW_RELEASE_PATH="${RELEASES_PATH}/${DEPLOYMENT_TIMESTAMP}" +CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" + +echo "πŸ“… Deployment version: ${DEPLOYMENT_TIMESTAMP}" + +echo "πŸš€ Enable and start docker service" +retry ssh deploy "sudo systemctl enable --now docker.service" + +echo "πŸ’Ύ Load the new docker image ($IMAGE_TAR)" +if [ ! -f "$IMAGE_TAR" ]; then + echo "Error: Docker image tar file not found: $IMAGE_TAR" + exit 1 +fi +retry docker load -i "$IMAGE_TAR" + +echo "πŸ“ Create versioned release directory" +ssh deploy "mkdir -p '${NEW_RELEASE_PATH}'" + +echo "πŸ’Ύ Copy new files to server" +# Check if server directory exists before copying +if [ -d "./server" ]; then + retry scp -pr ./server/* "deploy:${NEW_RELEASE_PATH}/" +else + echo "⚠️ No server directory found, erroring out" + exit 1 +fi + +echo "πŸ“ Create new .env file" +printf "%s" "${ENV_FILE_BASE64}" | base64 -d | ssh deploy "cat > '${NEW_RELEASE_PATH}/.env' && chmod 600 '${NEW_RELEASE_PATH}/.env'" + +echo "πŸ”‘ Set up certs" +ssh deploy "mkdir -p '${NEW_RELEASE_PATH}/certs' && chmod 550 '${NEW_RELEASE_PATH}/certs' && chown 99:root '${NEW_RELEASE_PATH}/certs'" +printf "%s" "$CF_PEM_CERT" | ssh deploy "cat > '${NEW_RELEASE_PATH}/certs/crt.pem' && chmod 440 '${NEW_RELEASE_PATH}/certs/crt.pem' && chown 99:root '${NEW_RELEASE_PATH}/certs/crt.pem'" +printf "%s" "$CF_PEM_CA" | ssh deploy "cat > '${NEW_RELEASE_PATH}/certs/ca.pem' && chmod 440 '${NEW_RELEASE_PATH}/certs/ca.pem' && chown 99:root '${NEW_RELEASE_PATH}/certs/ca.pem'" + +echo "πŸ”„ Prepare deployment (stop current containers)" +# Copy script to remote and execute with parameters +scp "${SCRIPT_DIR}/prepare-deployment.sh" deploy:/tmp/ +ssh deploy "chmod +x /tmp/prepare-deployment.sh && /tmp/prepare-deployment.sh '${REPO_PROJECT_PATH}' '${IS_PROD}' '${CURRENT_LINK_PATH}'" +ssh deploy "rm -f /tmp/prepare-deployment.sh" + +echo "πŸ“ Save deployment metadata" +ssh deploy "echo '${DEPLOYMENT_TIMESTAMP}' > '${NEW_RELEASE_PATH}/.deployment_version'" +ssh deploy "echo '${IS_PROD}' > '${NEW_RELEASE_PATH}/.deployment_env'" + +# Save previous version info for potential rollback +ssh deploy "if [ -L '${CURRENT_LINK_PATH}' ]; then readlink -f '${CURRENT_LINK_PATH}' > '${NEW_RELEASE_PATH}/.previous_version'; fi" + +echo "πŸ”— Update current symlink to new release" +ssh deploy "ln -sfn '${NEW_RELEASE_PATH}' '${CURRENT_LINK_PATH}'" + +# TODO: implement zero-downtime deployment +# echo "πŸš€ Start the new containers, zero-downtime" +# if [ "${PROD}" = true ]; then +# ssh deploy </dev/null | tail -n +6 | xargs -r rm -rf || true" + +echo "βœ… Deployment completed. Version: ${DEPLOYMENT_TIMESTAMP}" \ No newline at end of file diff --git a/scripts/generate-docker-tags.sh b/scripts/generate-docker-tags.sh new file mode 100644 index 0000000..b22dc5b --- /dev/null +++ b/scripts/generate-docker-tags.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +# Generate Docker tags based on git ref and environment +# Usage: ./generate-docker-tags.sh IMAGE_BASE GIT_SHA GIT_REF IS_PROD + +if [ $# -ne 4 ]; then + echo "Error: Invalid number of arguments" + echo "Usage: $0 IMAGE_BASE GIT_SHA GIT_REF IS_PROD" + exit 1 +fi + +IMAGE_BASE="$1" +GIT_SHA="$2" +GIT_REF="$3" +IS_PROD="$4" + +# Validate inputs +if [ -z "$IMAGE_BASE" ] || [ -z "$GIT_SHA" ]; then + echo "Error: IMAGE_BASE and GIT_SHA cannot be empty" + exit 1 +fi + +# Always include SHA tags +echo "${IMAGE_BASE}:sha-${GIT_SHA:0:7}" +echo "${IMAGE_BASE}:sha-${GIT_SHA}" + +# Handle version tags +if [[ "$GIT_REF" =~ ^refs/tags/v([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + PRERELEASE="${BASH_REMATCH[4]}" + + if [[ -z "$PRERELEASE" ]] && [[ "$IS_PROD" == "true" ]]; then + echo "${IMAGE_BASE}:latest" + echo "${IMAGE_BASE}:stable" + [[ "$MAJOR" -gt 0 ]] && echo "${IMAGE_BASE}:v${MAJOR}" + echo "${IMAGE_BASE}:v${MAJOR}.${MINOR}" + echo "${IMAGE_BASE}:v${MAJOR}.${MINOR}.${PATCH}" + else + echo "${IMAGE_BASE}:latest-staging" + echo "${IMAGE_BASE}:staging" + echo "${IMAGE_BASE}:v${MAJOR}.${MINOR}.${PATCH}-prerelease" + fi +elif [[ "$IS_PROD" == "false" ]]; then + echo "${IMAGE_BASE}:latest-staging" + echo "${IMAGE_BASE}:staging" +fi \ No newline at end of file diff --git a/scripts/health-check-and-rollback.sh b/scripts/health-check-and-rollback.sh new file mode 100644 index 0000000..446f1f5 --- /dev/null +++ b/scripts/health-check-and-rollback.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -euo pipefail + +# Perform health check and rollback if necessary +# Usage: ./health-check-and-rollback.sh REPO_PROJECT_PATH IS_PROD HEALTH_CHECK_URL [MAX_ATTEMPTS] + +if [ $# -lt 3 ]; then + echo "Error: Invalid number of arguments" + echo "Usage: $0 REPO_PROJECT_PATH IS_PROD HEALTH_CHECK_URL [MAX_ATTEMPTS]" + exit 1 +fi + +REPO_PROJECT_PATH="$1" +IS_PROD="$2" +HEALTH_CHECK_URL="$3" +MAX_ATTEMPTS="${4:-30}" + +CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" +RELEASES_PATH="${REPO_PROJECT_PATH}/releases" + +echo "πŸ₯ Performing health check..." +echo "Health check URL: $HEALTH_CHECK_URL" + +get_current_version() { + if [ -L "$CURRENT_LINK_PATH" ]; then + basename "$(readlink -f "$CURRENT_LINK_PATH")" + else + echo "unknown" + fi +} + +ATTEMPT=0 +while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do + # Check if the service is responding with 200 OK + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 10 "$HEALTH_CHECK_URL" || echo '000') + + if [ "$HTTP_CODE" = "200" ]; then + echo "βœ… Health check passed! (HTTP $HTTP_CODE)" + CURRENT_VERSION=$(get_current_version) + echo "πŸ“Œ Current version: ${CURRENT_VERSION}" + exit 0 + fi + + ATTEMPT=$((ATTEMPT + 1)) + if [ "$ATTEMPT" -eq "$MAX_ATTEMPTS" ]; then + echo "❌ Health check failed after $MAX_ATTEMPTS attempts (Last HTTP code: $HTTP_CODE)" + echo "πŸ”„ Rolling back deployment..." + + FAILED_VERSION=$(get_current_version) + echo "❌ Failed version: ${FAILED_VERSION}" + + # Check if we have a previous version to roll back to + if [ -f "${CURRENT_LINK_PATH}/.previous_version" ]; then + PREVIOUS_VERSION_PATH=$(cat "${CURRENT_LINK_PATH}/.previous_version") + PREVIOUS_VERSION=$(basename "$PREVIOUS_VERSION_PATH") + + if [ -d "$PREVIOUS_VERSION_PATH" ]; then + echo "πŸ”„ Rolling back to version: ${PREVIOUS_VERSION}" + + # Stop failed deployment containers + cd "$CURRENT_LINK_PATH" + echo "Stopping failed deployment containers..." + docker compose -f docker-compose_web.yml down || true + if [ "$IS_PROD" = "false" ]; then + docker compose -f docker-compose_staging.yml down || true + fi + docker compose -f docker-compose_core.yml down || true + + # Switch symlink back to previous version + ln -sfn "$PREVIOUS_VERSION_PATH" "$CURRENT_LINK_PATH" + + # Start previous version containers + cd "$CURRENT_LINK_PATH" + docker compose -f docker-compose_core.yml up -d --no-build + if [ "$IS_PROD" = "true" ]; then + docker compose -f docker-compose_web.yml up -d --no-build + else + docker compose -f docker-compose_web.yml -f docker-compose_staging.yml up -d --no-build + fi + + echo "βœ… Rollback completed to version: ${PREVIOUS_VERSION}" + + # Mark failed version + if [ -d "${RELEASES_PATH}/${FAILED_VERSION}" ]; then + touch "${RELEASES_PATH}/${FAILED_VERSION}/.failed" + echo "$(date): Health check failed, rolled back to ${PREVIOUS_VERSION}" > "${RELEASES_PATH}/${FAILED_VERSION}/.failure_reason" + fi + else + echo "❌ Previous version directory not found: $PREVIOUS_VERSION_PATH" + exit 1 + fi + else + echo "❌ No previous version information found. Cannot rollback!" + echo "πŸ’‘ This might be the first deployment or the previous version info is missing." + exit 1 + fi + exit 1 + fi + + echo "⏳ Waiting for service to be healthy... (attempt $ATTEMPT/$MAX_ATTEMPTS, HTTP code: $HTTP_CODE)" + sleep 10 +done \ No newline at end of file diff --git a/scripts/manage-releases.sh b/scripts/manage-releases.sh new file mode 100644 index 0000000..255c35c --- /dev/null +++ b/scripts/manage-releases.sh @@ -0,0 +1,120 @@ +#!/bin/bash +set -euo pipefail + +# Manage deployment releases +# Usage: ./manage-releases.sh REPO_PROJECT_PATH COMMAND [ARGS] + +if [ $# -lt 2 ]; then + echo "Error: Invalid number of arguments" + echo "Usage: $0 REPO_PROJECT_PATH COMMAND [ARGS]" + echo "Commands:" + echo " list - List all releases" + echo " current - Show current release" + echo " rollback VERSION - Rollback to specific version" + echo " cleanup [KEEP] - Clean up old releases (default: keep 5)" + exit 1 +fi + +REPO_PROJECT_PATH="$1" +COMMAND="$2" +CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" +RELEASES_PATH="${REPO_PROJECT_PATH}/releases" + +case "$COMMAND" in + list) + echo "πŸ“‹ Available releases:" + if [ -d "$RELEASES_PATH" ]; then + for release in $(ls -dt "${RELEASES_PATH}"/*/); do + version=$(basename "$release") + status="" + + # Check if it's current + if [ -L "$CURRENT_LINK_PATH" ] && [ "$(readlink -f "$CURRENT_LINK_PATH")" = "$(realpath "$release")" ]; then + status=" [CURRENT]" + fi + + # Check if it failed + if [ -f "${release}/.failed" ]; then + status="${status} [FAILED]" + fi + + echo " - ${version}${status}" + done + else + echo "No releases found" + fi + ;; + + current) + if [ -L "$CURRENT_LINK_PATH" ]; then + current_version=$(basename "$(readlink -f "$CURRENT_LINK_PATH")") + echo "πŸ“Œ Current version: ${current_version}" + else + echo "❌ No current deployment found" + fi + ;; + + rollback) + if [ $# -lt 3 ]; then + echo "Error: VERSION required for rollback" + exit 1 + fi + TARGET_VERSION="$3" + TARGET_PATH="${RELEASES_PATH}/${TARGET_VERSION}" + + if [ ! -d "$TARGET_PATH" ]; then + echo "Error: Version ${TARGET_VERSION} not found" + exit 1 + fi + + echo "πŸ”„ Rolling back to version: ${TARGET_VERSION}" + + # Read environment from target version + if [ -f "${TARGET_PATH}/.deployment_env" ]; then + IS_PROD=$(cat "${TARGET_PATH}/.deployment_env") + else + echo "Warning: Could not determine environment, assuming staging" + IS_PROD="false" + fi + + # Stop current containers + if [ -L "$CURRENT_LINK_PATH" ] && [ -d "$CURRENT_LINK_PATH" ]; then + cd "$CURRENT_LINK_PATH" + docker compose -f docker-compose_web.yml down || true + [ "$IS_PROD" = "false" ] && docker compose -f docker-compose_staging.yml down || true + docker compose -f docker-compose_core.yml down || true + fi + + # Update symlink + ln -sfn "$TARGET_PATH" "$CURRENT_LINK_PATH" + + # Start containers + cd "$CURRENT_LINK_PATH" + docker compose -f docker-compose_core.yml up -d --no-build + if [ "$IS_PROD" = "true" ]; then + docker compose -f docker-compose_web.yml up -d --no-build + else + docker compose -f docker-compose_web.yml -f docker-compose_staging.yml up -d --no-build + fi + + echo "βœ… Rollback completed" + ;; + + cleanup) + KEEP_COUNT="${3:-5}" + echo "πŸ—‘οΈ Cleaning up old releases (keeping last ${KEEP_COUNT})" + + if [ -d "$RELEASES_PATH" ]; then + cd "$RELEASES_PATH" + ls -dt */ 2>/dev/null | tail -n +$((KEEP_COUNT + 1)) | xargs -r rm -rf || true + echo "βœ… Cleanup completed" + else + echo "No releases directory found" + fi + ;; + + *) + echo "Error: Unknown command: $COMMAND" + exit 1 + ;; +esac \ No newline at end of file diff --git a/scripts/parse-repository-name.sh b/scripts/parse-repository-name.sh new file mode 100644 index 0000000..2e3aa80 --- /dev/null +++ b/scripts/parse-repository-name.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +# Parse repository name and generate project paths +# Usage: ./parse-repository-name.sh GITHUB_REPOSITORY + +if [ $# -eq 0 ]; then + echo "Error: No repository name provided" + echo "Usage: $0 GITHUB_REPOSITORY" + exit 1 +fi + +GITHUB_REPOSITORY="$1" + +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 + +REPO_NAME_ONLY=$(echo "$REPO" | cut -d'/' -f2) +REPO_PROJECT_PATH="/srv/${REPO_NAME_ONLY}" + +# Output in format that can be sourced - using printf %q for proper escaping +printf "export REPO=%q\n" "$REPO" +printf "export REPO_NAME_ONLY=%q\n" "$REPO_NAME_ONLY" +printf "export REPO_PROJECT_PATH=%q\n" "$REPO_PROJECT_PATH" \ No newline at end of file diff --git a/scripts/prepare-deployment.sh b/scripts/prepare-deployment.sh new file mode 100644 index 0000000..ae6ac29 --- /dev/null +++ b/scripts/prepare-deployment.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +# Prepare deployment by stopping containers +# Usage: ./prepare-deployment.sh REPO_PROJECT_PATH IS_PROD CURRENT_LINK_PATH + +if [ $# -ne 3 ]; then + echo "Error: Invalid number of arguments" + echo "Usage: $0 REPO_PROJECT_PATH IS_PROD CURRENT_LINK_PATH" + exit 1 +fi + +REPO_PROJECT_PATH="$1" +IS_PROD="$2" +CURRENT_LINK_PATH="$3" + +# Ensure base directory exists +if [ ! -d "$REPO_PROJECT_PATH" ]; then + echo "⚠️ Directory $REPO_PROJECT_PATH does not exist, creating it..." + mkdir -p "$REPO_PROJECT_PATH" +fi + +# If current symlink exists, stop containers in that directory +if [ -L "$CURRENT_LINK_PATH" ] && [ -d "$CURRENT_LINK_PATH" ]; then + echo "πŸ›‘ Stopping containers in current deployment..." + cd "$CURRENT_LINK_PATH" + + # Stop containers + if [ -f "docker-compose_web.yml" ]; then + docker compose -f docker-compose_web.yml down || true + fi + + if [ "$IS_PROD" = "false" ] && [ -f "docker-compose_staging.yml" ]; then + docker compose -f docker-compose_staging.yml down || true + fi + + if [ -f "docker-compose_core.yml" ]; then + docker compose -f docker-compose_core.yml down || true + fi + + echo "βœ… Containers stopped" +else + echo "ℹ️ No current deployment found (symlink doesn't exist or point to valid directory)" +fi \ No newline at end of file diff --git a/scripts/retry.sh b/scripts/retry.sh new file mode 100644 index 0000000..42ee35c --- /dev/null +++ b/scripts/retry.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Retry function with exponential backoff +# Usage: source retry.sh && retry + +retry() { + local max_attempts=3 + local delay=5 + local attempt=1 + + until "$@"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "Command failed after $max_attempts attempts: $*" + return 1 + fi + + echo "Command failed (attempt $attempt/$max_attempts): $*" + echo "Retrying in $delay seconds..." + sleep "$delay" + attempt=$((attempt + 1)) + delay=$((delay * 2)) # Exponential backoff + done +} \ No newline at end of file diff --git a/src/pkmntrade_club/__init__.py b/src/pkmntrade_club/__init__.py index e69de29..8d1f1f7 100644 --- a/src/pkmntrade_club/__init__.py +++ b/src/pkmntrade_club/__init__.py @@ -0,0 +1,5 @@ +"""pkmntrade.club - A django project for trading PokΓ©mon TCG Pocket Cards""" + +from pkmntrade_club._version import __version__, get_version, get_version_info + +__all__ = ['__version__', 'get_version', 'get_version_info'] diff --git a/src/pkmntrade_club/_version.py b/src/pkmntrade_club/_version.py new file mode 100644 index 0000000..6f90c71 --- /dev/null +++ b/src/pkmntrade_club/_version.py @@ -0,0 +1,61 @@ +from importlib.metadata import version, PackageNotFoundError +from setuptools_scm import get_version +""" +Version module for pkmntrade.club + +This module provides version information from git tags via setuptools-scm. +""" + +try: + __version__ = version("pkmntrade-club") +except PackageNotFoundError: + # Package is not installed, try to get version from setuptools_scm + try: + __version__ = get_version(root='../../..', relative_to=__file__) + except (ImportError, LookupError): + __version__ = "0.0.0+unknown" + +def get_version(): + """Return the current version.""" + return __version__ + +def get_version_info(): + """Return detailed version information.""" + import re + + # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") + match = re.match( + r'^(\d+)\.(\d+)\.(\d+)' + r'(?:\.dev(\d+))?' + r'(?:\+g([a-f0-9]+))?' + r'(?:-(.+))?$', + __version__ + ) + + if match: + major, minor, patch, dev, git_sha, prerelease = match.groups() + return { + 'version': __version__, + 'major': int(major), + 'minor': int(minor), + 'patch': int(patch), + 'dev': int(dev) if dev else None, + 'git_sha': git_sha, + 'prerelease': prerelease, + 'is_release': dev is None and not prerelease, + 'is_prerelease': bool(prerelease), + 'is_dev': dev is not None + } + + return { + 'version': __version__, + 'major': 0, + 'minor': 0, + 'patch': 0, + 'dev': None, + 'git_sha': None, + 'prerelease': None, + 'is_release': False, + 'is_prerelease': False, + 'is_dev': True +} \ No newline at end of file diff --git a/src/pkmntrade_club/common/context_processors.py b/src/pkmntrade_club/common/context_processors.py index 22b6b57..7950ded 100644 --- a/src/pkmntrade_club/common/context_processors.py +++ b/src/pkmntrade_club/common/context_processors.py @@ -3,4 +3,10 @@ from django.conf import settings def cache_settings(request): return { 'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, + } + +def version_info(request): + return { + 'VERSION': settings.VERSION, + 'VERSION_INFO': settings.VERSION_INFO, } \ No newline at end of file diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index 270ecdc..550b184 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -4,6 +4,8 @@ import environ import os import logging import sys +from django.utils.translation import gettext_lazy as _ +from pkmntrade_club._version import __version__, get_version_info # set default values to local dev values env = environ.Env( @@ -84,6 +86,9 @@ CACHE_TIMEOUT = env('CACHE_TIMEOUT') DISABLE_SIGNUPS = env('DISABLE_SIGNUPS') DISABLE_CACHE = env('DISABLE_CACHE') +VERSION = __version__ +VERSION_INFO = get_version_info() + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ @@ -213,6 +218,7 @@ TEMPLATES = [ "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "pkmntrade_club.common.context_processors.cache_settings", + "pkmntrade_club.common.context_processors.version_info", ], }, }, diff --git a/uv.lock b/uv.lock index 19bc266..2c2eefa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.12" [[package]] @@ -107,7 +106,7 @@ name = "click" version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } wheels = [ @@ -346,6 +345,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/78/2fb6ff7df06fe4ad31f3f9b9b80e682317b6d22188148dca52e0ec87bf4a/django_meta-2.4.2-py2.py3-none-any.whl", hash = "sha256:afc6b77c3885db0cd97883d1dc3df47f91a9c7951b2f4928fee91ca60a7d0ff2", size = 27792 }, ] +[[package]] +name = "django-parler" +version = "2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/2b/2423d31620efe8ab0d0390e60afab4f9cc2e62d4bf39fe0e05df0eef1b93/django-parler-2.3.tar.gz", hash = "sha256:2c8f5012ceb5e49af93b16ea3fe4d0c83d70b91b2d0f470c05d7d742b6f3083d", size = 69167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/38/11f1a7e3d56f3a6b74cf99e307f2554b741cadebc9b1c45b05e2ec1f35a2/django_parler-2.3-py3-none-any.whl", hash = "sha256:8f6c8061e4b5690f1ee2d8e5760940ef06bf78a5bfa033d11178377559c749cf", size = 83288 }, +] + [[package]] name = "django-tailwind-4" version = "0.1.4" @@ -606,6 +617,7 @@ dependencies = [ { name = "django-health-check" }, { name = "django-linear-migrations" }, { name = "django-meta" }, + { name = "django-parler" }, { name = "django-tailwind-4", extra = ["reload"] }, { name = "django-widget-tweaks" }, { name = "gevent" }, @@ -652,6 +664,7 @@ requires-dist = [ { name = "django-health-check", specifier = ">=3.18.3" }, { name = "django-linear-migrations", specifier = ">=2.17.0" }, { name = "django-meta", specifier = "==2.4.2" }, + { name = "django-parler", specifier = ">=2.3" }, { name = "django-tailwind-4", extras = ["reload"], specifier = "==0.1.4" }, { name = "django-widget-tweaks", specifier = "==1.5.0" }, { name = "gevent", specifier = "==25.4.1" },