Compare commits
2 commits
445be58cd3
...
a012c2a2f6
| Author | SHA1 | Date | |
|---|---|---|---|
| a012c2a2f6 | |||
| f20c4f9474 |
14 changed files with 719 additions and 233 deletions
341
.github/workflows/build_deploy.yml
vendored
341
.github/workflows/build_deploy.yml
vendored
|
|
@ -8,37 +8,37 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-deploy:
|
# Job 1: Build the Docker image
|
||||||
|
build:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Checkout the repo
|
- name: Checkout the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure scripts are executable
|
||||||
|
run: chmod +x scripts/*.sh
|
||||||
|
|
||||||
- name: Get full and partial repository name
|
- name: Get full and partial repository name
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
echo "GITHUB_REPOSITORY: ${{ github.repository }}"
|
# Parse repository name and set outputs
|
||||||
if [[ "${{ github.repository }}" == *".git" ]]; then
|
eval "$(./scripts/parse-repository-name.sh '${{ github.repository }}')"
|
||||||
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
|
|
||||||
echo "REPO=$REPO" >> $GITHUB_OUTPUT
|
echo "REPO=$REPO" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
REPO_NAME_ONLY=$(echo "$REPO" | cut -d'/' -f2)
|
|
||||||
echo "REPO_NAME_ONLY=$REPO_NAME_ONLY" >> $GITHUB_OUTPUT
|
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
|
echo "REPO_PROJECT_PATH=$REPO_PROJECT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set PROD environment variable
|
- name: Set PROD environment variable
|
||||||
|
id: env
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Exit script on any error"
|
echo "✅ Exit script on any error"
|
||||||
set -eu -o pipefail
|
set -eu -o pipefail
|
||||||
|
|
@ -57,144 +57,99 @@ jobs:
|
||||||
echo "🖊️ Writing determined values to GITHUB_ENV:"
|
echo "🖊️ Writing determined values to GITHUB_ENV:"
|
||||||
echo "PROD=${prod_value}" >> $GITHUB_ENV
|
echo "PROD=${prod_value}" >> $GITHUB_ENV
|
||||||
echo "PROD=${prod_value} -> GITHUB_ENV"
|
echo "PROD=${prod_value} -> GITHUB_ENV"
|
||||||
|
echo "prod=${prod_value}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Generate tags
|
- name: Generate tags
|
||||||
id: generated_docker_tags
|
id: generated_docker_tags
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Exit script on any error"
|
echo "✅ Exit script on any error"
|
||||||
set -eu -o pipefail
|
set -eu -o pipefail
|
||||||
|
|
||||||
# echo current shell
|
# Use the script to generate tags
|
||||||
echo "Current shell: $SHELL"
|
TAG_LIST=$(./scripts/generate-docker-tags.sh \
|
||||||
|
"${{ steps.meta.outputs.REPO }}" \
|
||||||
IMAGE_BASE_NAME="${{ steps.meta.outputs.REPO }}"
|
"${{ github.sha }}" \
|
||||||
GIT_SHA="${{ github.sha }}"
|
"${{ github.ref }}" \
|
||||||
GIT_REF="${{ github.ref }}"
|
"$PROD")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
echo "Final list of generated tags:"
|
echo "Final list of generated tags:"
|
||||||
printf "%s\n" "${TAG_LIST[@]}"
|
echo "$TAG_LIST"
|
||||||
|
|
||||||
if [[ -z "$GIT_SHA" || ${#TAG_LIST[@]} -lt 4 ]]; then
|
TAG_COUNT=$(echo "$TAG_LIST" | wc -l)
|
||||||
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."
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Output the tags for the docker build action (output name is 'tag')
|
# Output the tags for the docker build action
|
||||||
{
|
{
|
||||||
echo "tag<<EOF"
|
echo "tag<<EOF"
|
||||||
printf "%s\n" "${TAG_LIST[@]}"
|
echo "$TAG_LIST"
|
||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Run prebuild tasks
|
- 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: |
|
run: |
|
||||||
echo "🔄 Chdir to src/pkmntrade_club/theme/static_src"
|
pip install setuptools-scm
|
||||||
cd src/pkmntrade_club/theme/static_src
|
VERSION=$(python -c "from setuptools_scm import get_version; print(get_version())")
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
||||||
|
|
||||||
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
|
|
||||||
- name: Build container
|
- name: Build container
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
outputs: type=docker,dest=${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
|
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 }}
|
tags: ${{ steps.generated_docker_tags.outputs.tag }}
|
||||||
build-args: CACHE_DIR=${{ runner.temp }}/.cache/dockerfile-cache
|
build-args: |
|
||||||
|
VERSION=${{ env.VERSION }}
|
||||||
context: .
|
context: .
|
||||||
#cache-from: type=local,src=${{ runner.temp }}/.cache/buildx-cache
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
#cache-to: type=local,src=${{ runner.temp }}/.cache/buildx-cache-new,mode=max
|
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||||
# - name: Rotate cache # along with cache-from & cache-to: prevents cache from growing indefinitely
|
|
||||||
# run: |
|
- name: Rotate cache
|
||||||
# rm -rf ${{ runner.temp }}/.cache/buildx-cache
|
run: |
|
||||||
# mv ${{ runner.temp }}/.cache/buildx-cache-new ${{ runner.temp }}/.cache/buildx-cache
|
rm -rf /tmp/.buildx-cache
|
||||||
# - name: Upload container as artifact
|
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||||
# uses: actions/upload-artifact@v4
|
|
||||||
# with:
|
- name: Upload container as artifact
|
||||||
# name: ${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
|
uses: actions/upload-artifact@v4
|
||||||
# path: ${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar
|
with:
|
||||||
# if-no-files-found: error
|
name: docker-image
|
||||||
# compression-level: 0
|
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
|
- name: Get Deploy Secrets
|
||||||
uses: bitwarden/sm-action@v2
|
uses: bitwarden/sm-action@v2
|
||||||
with:
|
with:
|
||||||
|
|
@ -205,6 +160,7 @@ jobs:
|
||||||
9aefe34e-c2cf-442e-973c-b2dd0032b6cf > ENV_FILE_BASE64
|
9aefe34e-c2cf-442e-973c-b2dd0032b6cf > ENV_FILE_BASE64
|
||||||
d3bb47f8-bfc0-4a61-9cee-b2df0147a02a > CF_PEM_CERT
|
d3bb47f8-bfc0-4a61-9cee-b2df0147a02a > CF_PEM_CERT
|
||||||
5f658ddf-aadd-4464-b501-b2df0147c338 > CF_PEM_CA
|
5f658ddf-aadd-4464-b501-b2df0147c338 > CF_PEM_CA
|
||||||
|
|
||||||
- name: Set up SSH
|
- name: Set up SSH
|
||||||
run: |
|
run: |
|
||||||
mkdir -p $HOME/.ssh
|
mkdir -p $HOME/.ssh
|
||||||
|
|
@ -224,105 +180,40 @@ jobs:
|
||||||
ControlPath $HOME/.ssh/control-%C
|
ControlPath $HOME/.ssh/control-%C
|
||||||
ControlPersist yes
|
ControlPersist yes
|
||||||
END
|
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: |
|
run: |
|
||||||
echo "✅ Exit script on any error"
|
echo "✅ Exit script on any error"
|
||||||
set -eu -o pipefail
|
set -eu -o pipefail
|
||||||
|
./scripts/deploy-to-server.sh
|
||||||
|
|
||||||
echo "⚙️ Set docker host to ssh://deploy so that all docker commands are run on the remote server"
|
- name: Health Check and Rollback
|
||||||
export DOCKER_HOST=ssh://deploy
|
run: |
|
||||||
|
# Determine the correct URL based on environment
|
||||||
echo "🚀 Enable and start docker service"
|
if [ "${{ needs.build.outputs.prod }}" = "true" ]; then
|
||||||
ssh deploy "sudo systemctl enable --now docker.service"
|
# Ensure PRODUCTION_DOMAIN is set
|
||||||
|
if [ -z "${{ vars.PRODUCTION_DOMAIN }}" ]; then
|
||||||
echo "💾 Load the new docker image (${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar)"
|
echo "Error: PRODUCTION_DOMAIN is not set"
|
||||||
docker load -i "${{ runner.temp }}/${{ steps.meta.outputs.REPO_NAME_ONLY }}-${{ github.ref_name }}_${{ github.sha }}.tar"
|
exit 1
|
||||||
|
|
||||||
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
|
fi
|
||||||
if [ -f "docker-compose_core.yml" ] && ! diff -q docker-compose_core.yml new/docker-compose_core.yml; then
|
HEALTH_CHECK_URL="https://${{ vars.PRODUCTION_DOMAIN }}/health/"
|
||||||
echo "⚠️ docker-compose_core.yml has changed, stopping and removing old core containers"
|
|
||||||
docker compose -f docker-compose_core.yml down
|
|
||||||
else
|
else
|
||||||
echo "⚠️ No changes to docker-compose_core.yml, but reloading due to volume mounts being tied to old directories"
|
# Ensure STAGING_DOMAIN is set
|
||||||
docker compose -f docker-compose_core.yml down
|
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
|
fi
|
||||||
|
|
||||||
echo "🔄 Backup old files (exclude new and backup directories)"
|
# Copy script to remote and execute
|
||||||
mkdir -p ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/new/backup
|
scp scripts/health-check-and-rollback.sh deploy:/tmp/
|
||||||
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/ ';'
|
ssh deploy "chmod +x /tmp/health-check-and-rollback.sh"
|
||||||
fi
|
ssh deploy "/tmp/health-check-and-rollback.sh '${{ needs.build.outputs.repo-path }}' '${{ needs.build.outputs.prod }}' '$HEALTH_CHECK_URL' 30"
|
||||||
EOF
|
ssh deploy "rm -f /tmp/health-check-and-rollback.sh"
|
||||||
|
|
||||||
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}}"
|
|
||||||
|
|
||||||
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"
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pkmntrade-club"
|
name = "pkmntrade-club"
|
||||||
version = "0.1.0"
|
dynamic = ["version"]
|
||||||
description = "A django project for trading Pokémon TCG Pocket Cards"
|
description = "A django project for trading Pokémon TCG Pocket Cards"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
@ -69,6 +69,7 @@ dependencies = [
|
||||||
"typing-extensions==4.9.0",
|
"typing-extensions==4.9.0",
|
||||||
"urllib3==1.26.14",
|
"urllib3==1.26.14",
|
||||||
"whitenoise==6.7.0",
|
"whitenoise==6.7.0",
|
||||||
|
"django-parler>=2.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
@ -79,3 +80,8 @@ Homepage = "https://pkmntrade.club"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
version_scheme = "no-guess-dev"
|
||||||
|
tag_regex = "^v(?P<version>[0-9]+(?:\\.[0-9]+)*(?:-.*)?)"
|
||||||
|
fallback_version = "0.0.0+unknown"
|
||||||
|
|
|
||||||
124
scripts/deploy-to-server.sh
Normal file
124
scripts/deploy-to-server.sh
Normal file
|
|
@ -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 <<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 "🚀 Start the new containers"
|
||||||
|
if [ "$IS_PROD" = "true" ]; then
|
||||||
|
retry ssh deploy "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -f docker-compose_web.yml up -d --no-build"
|
||||||
|
else
|
||||||
|
retry ssh deploy "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -f docker-compose_web.yml -f docker-compose_staging.yml up -d --no-build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🧹 Prune unused Docker resources"
|
||||||
|
ssh deploy "docker system prune -f"
|
||||||
|
|
||||||
|
echo "🗑️ Clean up old releases (keep last 5)"
|
||||||
|
ssh deploy "cd '${RELEASES_PATH}' && ls -dt */ 2>/dev/null | tail -n +6 | xargs -r rm -rf || true"
|
||||||
|
|
||||||
|
echo "✅ Deployment completed. Version: ${DEPLOYMENT_TIMESTAMP}"
|
||||||
49
scripts/generate-docker-tags.sh
Normal file
49
scripts/generate-docker-tags.sh
Normal file
|
|
@ -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
|
||||||
102
scripts/health-check-and-rollback.sh
Normal file
102
scripts/health-check-and-rollback.sh
Normal file
|
|
@ -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
|
||||||
120
scripts/manage-releases.sh
Normal file
120
scripts/manage-releases.sh
Normal file
|
|
@ -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
|
||||||
36
scripts/parse-repository-name.sh
Normal file
36
scripts/parse-repository-name.sh
Normal file
|
|
@ -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"
|
||||||
44
scripts/prepare-deployment.sh
Normal file
44
scripts/prepare-deployment.sh
Normal file
|
|
@ -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
|
||||||
23
scripts/retry.sh
Normal file
23
scripts/retry.sh
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Retry function with exponential backoff
|
||||||
|
# Usage: source retry.sh && retry <command>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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']
|
||||||
61
src/pkmntrade_club/_version.py
Normal file
61
src/pkmntrade_club/_version.py
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -4,3 +4,9 @@ def cache_settings(request):
|
||||||
return {
|
return {
|
||||||
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
|
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def version_info(request):
|
||||||
|
return {
|
||||||
|
'VERSION': settings.VERSION,
|
||||||
|
'VERSION_INFO': settings.VERSION_INFO,
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import environ
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import sys
|
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
|
# set default values to local dev values
|
||||||
env = environ.Env(
|
env = environ.Env(
|
||||||
|
|
@ -84,6 +86,9 @@ CACHE_TIMEOUT = env('CACHE_TIMEOUT')
|
||||||
DISABLE_SIGNUPS = env('DISABLE_SIGNUPS')
|
DISABLE_SIGNUPS = env('DISABLE_SIGNUPS')
|
||||||
DISABLE_CACHE = env('DISABLE_CACHE')
|
DISABLE_CACHE = env('DISABLE_CACHE')
|
||||||
|
|
||||||
|
VERSION = __version__
|
||||||
|
VERSION_INFO = get_version_info()
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
|
||||||
|
|
||||||
|
|
@ -213,6 +218,7 @@ TEMPLATES = [
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
"pkmntrade_club.common.context_processors.cache_settings",
|
"pkmntrade_club.common.context_processors.cache_settings",
|
||||||
|
"pkmntrade_club.common.context_processors.version_info",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
17
uv.lock
generated
17
uv.lock
generated
|
|
@ -1,5 +1,4 @@
|
||||||
version = 1
|
version = 1
|
||||||
revision = 1
|
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -107,7 +106,7 @@ name = "click"
|
||||||
version = "8.2.1"
|
version = "8.2.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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 }
|
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
|
||||||
wheels = [
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "django-tailwind-4"
|
name = "django-tailwind-4"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
|
@ -606,6 +617,7 @@ dependencies = [
|
||||||
{ name = "django-health-check" },
|
{ name = "django-health-check" },
|
||||||
{ name = "django-linear-migrations" },
|
{ name = "django-linear-migrations" },
|
||||||
{ name = "django-meta" },
|
{ name = "django-meta" },
|
||||||
|
{ name = "django-parler" },
|
||||||
{ name = "django-tailwind-4", extra = ["reload"] },
|
{ name = "django-tailwind-4", extra = ["reload"] },
|
||||||
{ name = "django-widget-tweaks" },
|
{ name = "django-widget-tweaks" },
|
||||||
{ name = "gevent" },
|
{ name = "gevent" },
|
||||||
|
|
@ -652,6 +664,7 @@ requires-dist = [
|
||||||
{ name = "django-health-check", specifier = ">=3.18.3" },
|
{ name = "django-health-check", specifier = ">=3.18.3" },
|
||||||
{ name = "django-linear-migrations", specifier = ">=2.17.0" },
|
{ name = "django-linear-migrations", specifier = ">=2.17.0" },
|
||||||
{ name = "django-meta", specifier = "==2.4.2" },
|
{ 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-tailwind-4", extras = ["reload"], specifier = "==0.1.4" },
|
||||||
{ name = "django-widget-tweaks", specifier = "==1.5.0" },
|
{ name = "django-widget-tweaks", specifier = "==1.5.0" },
|
||||||
{ name = "gevent", specifier = "==25.4.1" },
|
{ name = "gevent", specifier = "==25.4.1" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue