359 lines
No EOL
18 KiB
YAML
359 lines
No EOL
18 KiB
YAML
name: Build & Deploy
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
push:
|
||
branches: [main]
|
||
pull_request:
|
||
branches: [main]
|
||
|
||
jobs:
|
||
build-deploy:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- name: Checkout the repo
|
||
uses: actions/checkout@v4
|
||
- name: 🏗 Set up yq
|
||
uses: frenck/action-setup-yq@v1
|
||
- name: Get full and partial repository name
|
||
id: meta
|
||
run: |
|
||
echo "GITHUB_REPOSITORY: ${{ github.repository }}"
|
||
repo_temp=""
|
||
|
||
if [[ "${{ github.repository }}" == git@* && "${{ github.repository }}" == *:* && "${{ github.repository }}" == *.git ]]; then
|
||
echo "Detected SSH style git remote (e.g. git@host:owner/repo.git)"
|
||
# Extracts 'owner/repo' from 'git@host:owner/repo.git'
|
||
repo_temp=$(echo "${{ github.repository }}" | sed 's/\.git$//' | awk -F: '{print $2}')
|
||
elif [[ "${{ github.repository }}" == "https://"* && "${{ github.repository }}" == *.git ]]; then
|
||
echo "Detected HTTPS URL ending in .git (e.g. https://host/owner/repo.git)"
|
||
# Extracts 'owner/repo' from 'https://host/owner/repo.git'
|
||
repo_temp=$(echo "${{ github.repository }}" | sed 's/\.git$//' | awk -F/ '{print $(NF-1)"/"$(NF)}')
|
||
elif [[ "${{ github.repository }}" == *.git ]]; then # Catches other cases ending in .git, like owner/repo.git or other path based .git repos
|
||
echo "Detected path-like git repo ending in .git (e.g. owner/repo.git)"
|
||
# Extracts 'owner/repo' from 'owner/repo.git'
|
||
repo_temp=$(echo "${{ github.repository }}" | sed 's/\.git$//')
|
||
else
|
||
echo "Assuming owner/repo format (e.g. owner/repo)"
|
||
repo_temp="${{ github.repository }}"
|
||
fi
|
||
|
||
# Sanitize and lowercase for Docker image naming conventions
|
||
REPO=$(echo "$repo_temp" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9\/-]/-/g' -e 's/--\+/-/g' -e 's/^-//g' -e 's/-$//g')
|
||
echo "REPO (image base name): $REPO"
|
||
echo "REPO=$REPO" >> $GITHUB_OUTPUT
|
||
|
||
REPO_NAME_ONLY=$(echo "$REPO" | cut -d'/' -f2)
|
||
echo "REPO_NAME_ONLY (project name): $REPO_NAME_ONLY"
|
||
echo "REPO_NAME_ONLY=$REPO_NAME_ONLY" >> $GITHUB_OUTPUT
|
||
|
||
REPO_PROJECT_PATH=/srv/$(echo "$REPO_NAME_ONLY")
|
||
echo "REPO_PROJECT_PATH: $REPO_PROJECT_PATH"
|
||
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
|
||
run: |
|
||
echo "✅ Exit script on any error"
|
||
set -eu -o pipefail
|
||
|
||
prod_value=""
|
||
|
||
echo "🔍 Check if PROD is set via vars; if not, determine from github.ref"
|
||
if [ -z "${{ vars.PROD }}" ]; then
|
||
prod_value="${{ startsWith(github.ref, 'refs/tags/v') && !endsWith(github.ref, '-prerelease') }}"
|
||
echo "📦 PROD mode unset, determined from github.ref (starts with v and does not end with -prerelease?): ${prod_value}"
|
||
else
|
||
prod_value="${{ vars.PROD }}"
|
||
echo "📦 PROD mode already set to: ${prod_value}"
|
||
fi
|
||
|
||
echo "🖊️ Writing determined values to GITHUB_ENV:"
|
||
echo "PROD=${prod_value}" >> $GITHUB_ENV
|
||
echo "PROD=${prod_value} -> GITHUB_ENV"
|
||
- 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
|
||
|
||
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."
|
||
exit 1
|
||
fi
|
||
|
||
# Output the tags for the docker build action (output name is 'tag')
|
||
{
|
||
echo "tag<<EOF"
|
||
printf "%s\n" "${TAG_LIST[@]}"
|
||
echo "EOF"
|
||
} >> "$GITHUB_OUTPUT"
|
||
- name: Run prebuild tasks
|
||
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
|
||
- name: Build container
|
||
id: 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
|
||
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
|
||
- name: Update docker compose and "pin" just built image by adding image digest to tag
|
||
env:
|
||
IMAGE_DIGEST: ${{ steps.build_container.outputs.DIGEST }}
|
||
IMAGE_BASE_NAME: ${{ steps.meta.outputs.REPO }}
|
||
run: |
|
||
set -eu -o pipefail
|
||
echo "PROD_STATUS: ${PROD}"
|
||
echo "IMAGE_DIGEST: ${IMAGE_DIGEST}"
|
||
echo "IMAGE_BASE_NAME (for compose update): ${IMAGE_BASE_NAME}"
|
||
|
||
TARGET_COMPOSE_FILE=""
|
||
SERVICE_YQ_PATH=""
|
||
IMAGE_TAG=""
|
||
|
||
echo "Checking yq version..."
|
||
yq --version
|
||
|
||
if [ "${PROD}" = "true" ]; then
|
||
TARGET_COMPOSE_FILE="./server/docker-compose_web.yml"
|
||
SERVICE1_YQ_PATH=".services.web"
|
||
SERVICE2_YQ_PATH=".services.celery"
|
||
IMAGE_TAG="stable"
|
||
|
||
echo "Updating PROD configuration in $TARGET_COMPOSE_FILE for service $SERVICE_YQ_PATH"
|
||
yq -i "${SERVICE1_YQ_PATH}.image = \"${IMAGE_BASE_NAME}:${IMAGE_TAG}@${IMAGE_DIGEST}\"" "$TARGET_COMPOSE_FILE"
|
||
yq -i "${SERVICE2_YQ_PATH}.image = \"${IMAGE_BASE_NAME}:${IMAGE_TAG}@${IMAGE_DIGEST}\"" "$TARGET_COMPOSE_FILE"
|
||
else
|
||
TARGET_COMPOSE_FILE="./server/docker-compose_staging.yml"
|
||
SERVICE1_YQ_PATH=".services.\"web-staging\""
|
||
SERVICE2_YQ_PATH=".services.\"celery-staging\""
|
||
IMAGE_TAG="staging"
|
||
|
||
echo "Updating STAGING configuration in $TARGET_COMPOSE_FILE for service $SERVICE_YQ_PATH"
|
||
yq -i "${SERVICE1_YQ_PATH}.image = \"${IMAGE_BASE_NAME}:${IMAGE_TAG}@${IMAGE_DIGEST}\"" "$TARGET_COMPOSE_FILE"
|
||
yq -i "${SERVICE2_YQ_PATH}.image = \"${IMAGE_BASE_NAME}:${IMAGE_TAG}@${IMAGE_DIGEST}\"" "$TARGET_COMPOSE_FILE"
|
||
fi
|
||
|
||
echo "Successfully updated $TARGET_COMPOSE_FILE. Image pinned to:"
|
||
yq "${SERVICE_YQ_PATH}.image" "$TARGET_COMPOSE_FILE"
|
||
- name: Get Deploy Secrets
|
||
uses: bitwarden/sm-action@v2
|
||
with:
|
||
access_token: ${{ secrets.BW_ACCESS_TOKEN }}
|
||
secrets: |
|
||
27a8da8d-5fb4-4f58-baaf-b2dd0032eca8 > DEPLOY_HOST
|
||
4cf9ab8d-1772-4ab0-9219-b2dd003315d4 > DEPLOY_KEY
|
||
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
|
||
echo -e "${DEPLOY_KEY}" > $HOME/.ssh/deploy.key
|
||
chmod 700 $HOME/.ssh
|
||
chmod 600 $HOME/.ssh/deploy.key
|
||
|
||
cat >>$HOME/.ssh/config <<END
|
||
Host deploy
|
||
HostName ${DEPLOY_HOST}
|
||
Port ${{ vars.DEPLOY_PORT }}
|
||
User ${{ vars.DEPLOY_USER }}
|
||
IdentityFile $HOME/.ssh/deploy.key
|
||
UserKnownHostsFile /dev/null
|
||
StrictHostKeyChecking no
|
||
ControlMaster auto
|
||
ControlPath $HOME/.ssh/control-%C
|
||
ControlPersist yes
|
||
END
|
||
- name: Run Deploy Script
|
||
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 "🛑 Stop and remove containers before updating compose files"
|
||
#ssh deploy "cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}} && docker compose -f docker-compose_core.yml down"
|
||
if [ "${PROD}" = true ]; then
|
||
ssh deploy "cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}} && docker compose -f docker-compose_web.yml down"
|
||
else
|
||
ssh deploy "cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}} && docker compose -f docker-compose_staging.yml down"
|
||
fi
|
||
|
||
echo "💾 Copy files to server"
|
||
ssh deploy "mkdir -p ${{ steps.meta.outputs.REPO_PROJECT_PATH}}"
|
||
scp -pr ./server/* deploy:${{ steps.meta.outputs.REPO_PROJECT_PATH}}/
|
||
|
||
echo "📝 Create .env file"
|
||
printf "%s" "${ENV_FILE_BASE64}" | base64 -d | ssh deploy "cat > ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/.env && chmod 600 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/.env"
|
||
|
||
echo "🔑 Set up certificates"
|
||
ssh deploy "mkdir -p ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs && chmod 550 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs && chown 99:root ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs"
|
||
printf "%s" "$CF_PEM_CERT" | ssh deploy "cat > ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs/crt.pem && chmod 440 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs/crt.pem && chown 99:root ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs/crt.pem"
|
||
printf "%s" "$CF_PEM_CA" | ssh deploy "cat > ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs/ca.pem && chmod 440 ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs/ca.pem && chown 99:root ${{ steps.meta.outputs.REPO_PROJECT_PATH}}/certs/ca.pem"
|
||
|
||
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_staging.yml up -d --no-build"
|
||
fi
|
||
|
||
# echo "🚀 Start the new containers, zero-downtime"
|
||
# if [ "${PROD}" = true ]; then
|
||
# ssh deploy <<<END
|
||
# 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
|
||
# END
|
||
# else
|
||
# ssh deploy <<<END
|
||
# 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
|
||
# END
|
||
# fi
|
||
|
||
echo "🧹 Prune all unused images"
|
||
docker system prune -f |