From 6bf6bf4cbe47c0dbcb752078d342579e6dcd0f85 Mon Sep 17 00:00:00 2001 From: Michael Schiemer Date: Sat, 1 Nov 2025 17:04:03 +0100 Subject: [PATCH] ci: optimise docker workflows --- .gitea/workflows/build-image.yml | 535 +++++++++++++++++++++++++++++-- .gitea/workflows/cache-warm.yml | 227 +++++++++++++ .gitea/workflows/ci.yml | 109 +++++++ Dockerfile.production | 16 +- scripts/ci/clone_repo.sh | 41 +++ 5 files changed, 887 insertions(+), 41 deletions(-) create mode 100644 .gitea/workflows/cache-warm.yml create mode 100644 .gitea/workflows/ci.yml create mode 100755 scripts/ci/clone_repo.sh diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index 27d70ba2..4c79cc48 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -4,7 +4,7 @@ run-name: Build Image - ${{ github.ref_name }} - ${{ github.sha }} on: push: - branches: [ main, develop, staging ] + branches: [ main, staging ] paths-ignore: - '**.md' - 'docs/**' @@ -14,35 +14,433 @@ on: description: 'Branch to build from' required: false default: 'main' + force_build: + description: 'Force image build even if no runtime changes detected' + type: boolean + required: false + default: false env: REGISTRY: registry.michaelschiemer.de IMAGE_NAME: framework + RUNTIME_IMAGE_NAME: framework-runtime jobs: + # Job 0: Detect if a new image build is required + changes: + name: Determine Build Necessity + runs-on: ubuntu-latest + outputs: + needs_build: ${{ steps.filter.outputs.needs_build }} + changed_files: ${{ steps.filter.outputs.changed_files }} + needs_runtime_build: ${{ steps.filter.outputs.needs_runtime_build }} + steps: + - name: Download CI helpers + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + REF="${{ github.sha }}" + if [ -z "$REF" ]; then + REF="${{ github.ref_name }}" + fi + if [ -z "$REF" ]; then + REF="${{ inputs.branch || 'main' }}" + fi + URL="https://git.michaelschiemer.de/${{ github.repository }}/raw/${REF}/scripts/ci/clone_repo.sh" + mkdir -p /tmp/ci-tools + if [ -n "$CI_TOKEN" ]; then + curl -sfL -u "$CI_TOKEN:x-oauth-basic" "$URL" -o /tmp/ci-tools/clone_repo.sh + else + curl -sfL "$URL" -o /tmp/ci-tools/clone_repo.sh + fi + chmod +x /tmp/ci-tools/clone_repo.sh + + - name: Analyse changed files + id: filter + shell: bash + env: + FORCE_BUILD: ${{ inputs.force_build }} + EVENT_NAME: ${{ github.event_name }} + EVENT_BEFORE: ${{ github.event.before }} + INPUT_BRANCH: ${{ inputs.branch }} + REF_NAME_GITHUB: ${{ github.ref_name }} + run: | + set -euo pipefail + + FORCE="${FORCE_BUILD:-false}" + if [ "$EVENT_NAME" != "workflow_dispatch" ]; then + FORCE="false" + fi + + REF_NAME="$REF_NAME_GITHUB" + if [ -z "$REF_NAME" ]; then + REF_NAME="$INPUT_BRANCH" + fi + if [ -z "$REF_NAME" ]; then + REF_NAME="main" + fi + + REPO="${{ github.repository }}" + WORKDIR="/workspace/repo" + + export CI_REPOSITORY="$REPO" + export CI_TOKEN="${{ secrets.CI_TOKEN }}" + export CI_REF_NAME="$REF_NAME" + export CI_INPUT_BRANCH="$INPUT_BRANCH" + export CI_DEFAULT_BRANCH="main" + export CI_TARGET_DIR="$WORKDIR" + export CI_FETCH_DEPTH="2" + + /tmp/ci-tools/clone_repo.sh + + cd "$WORKDIR" + + CHANGED_FILES="" + + if [ "$EVENT_NAME" = "push" ] && [ -n "$EVENT_BEFORE" ]; then + if git rev-parse "$EVENT_BEFORE" >/dev/null 2>&1; then + CHANGED_FILES="$(git diff --name-only "$EVENT_BEFORE" HEAD || true)" + else + git fetch origin "$EVENT_BEFORE" --depth 1 || true + if git rev-parse "$EVENT_BEFORE" >/dev/null 2>&1; then + CHANGED_FILES="$(git diff --name-only "$EVENT_BEFORE" HEAD || true)" + fi + fi + fi + + if [ -z "$CHANGED_FILES" ]; then + if git rev-parse HEAD^ >/dev/null 2>&1; then + CHANGED_FILES="$(git diff --name-only HEAD^ HEAD || true)" + else + echo "ℹ️ Erweiterter Fetch für Diff-Ermittlung" + git fetch origin "$REF_NAME" --depth 50 || true + if git rev-parse HEAD^ >/dev/null 2>&1; then + CHANGED_FILES="$(git diff --name-only HEAD^ HEAD || true)" + fi + fi + fi + + if [ -z "$CHANGED_FILES" ] && [ "$FORCE" != "true" ]; then + # No diff information available; fall back to building to stay safe + echo "⚠️ Keine Änderungsinformation gefunden – bilde Image sicherheitshalber." + echo "needs_build=true" >> "$GITHUB_OUTPUT" + echo "changed_files=" >> "$GITHUB_OUTPUT" + echo "needs_runtime_build=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + NEEDS_BUILD=true + SUMMARY="Runtime-relevante Änderungen erkannt" + RUNTIME_BUILD=false + RUNTIME_PATTERN='^(Dockerfile\\.production|Dockerfile\\.runtime|docker/php/|install-php85\\.sh)' + + if [ -n "$CHANGED_FILES" ]; then + NEEDS_BUILD=false + OTHER_NON_IGNORED=false + IGNORE_PATTERN='^(docs/|docs$|tests/|tests$|tests-e2e/|\.github/|\.gitea/|\.idea/|\.vscode/|\.husky/|.*\.md$|.*\.MD$|LICENSE$|CHANGELOG|CHANGELOG\.md$|\.editorconfig$|\.gitignore$)' + BUILD_TRIGGER_PATTERN='^(src/|resources/|config/|app/|public/|composer\.json$|composer\.lock$|composer/|package\.json$|package-lock\.json$|pnpm-lock\.yaml$|yarn\.lock$|Dockerfile\.production$|Dockerfile\.runtime$|docker-compose\.production\.yml$|docker/|vite\.config\.(js|ts)$|tsconfig\.json$|babel\.config\.js$|Makefile$|artisan$)' + while IFS= read -r FILE; do + [ -z "$FILE" ] && continue + if echo "$FILE" | grep -Eq "$RUNTIME_PATTERN"; then + RUNTIME_BUILD=true + fi + if echo "$FILE" | grep -Eq "$IGNORE_PATTERN"; then + continue + fi + if echo "$FILE" | grep -Eq "$BUILD_TRIGGER_PATTERN"; then + NEEDS_BUILD=true + continue + fi + OTHER_NON_IGNORED=true + done <<< "$CHANGED_FILES" + + if [ "$NEEDS_BUILD" = "false" ] && [ "$OTHER_NON_IGNORED" = "false" ]; then + SUMMARY="Nur Doku-/Teständerungen – Container-Build wird übersprungen" + elif [ "$NEEDS_BUILD" = "false" ] && [ "$OTHER_NON_IGNORED" = "true" ]; then + SUMMARY="Keine Build-Trigger gefunden – Container-Build wird übersprungen" + fi + else + RUNTIME_BUILD=true + fi + + if [ "$FORCE" = "true" ]; then + NEEDS_BUILD=true + SUMMARY="Manuell erzwungener Build" + RUNTIME_BUILD=true + fi + + PRETTY_CHANGES="$(printf '%s' "$CHANGED_FILES" | tr '\n' ', ' | sed 's/, $//')" + if [ -z "$PRETTY_CHANGES" ]; then + PRETTY_CHANGES="" + fi + + echo "📄 Änderungen: $PRETTY_CHANGES" + echo "ℹ️ Ergebnis: $SUMMARY" + echo "🔁 Runtime-Rebuild erforderlich: $RUNTIME_BUILD" + + echo "needs_build=$NEEDS_BUILD" >> "$GITHUB_OUTPUT" + echo "changed_files=$PRETTY_CHANGES" >> "$GITHUB_OUTPUT" + echo "needs_runtime_build=$RUNTIME_BUILD" >> "$GITHUB_OUTPUT" + + runtime-base: + name: Build Runtime Base Image + needs: changes + runs-on: docker-build + outputs: + image_ref: ${{ steps.set-result.outputs.image_ref }} + built: ${{ steps.set-result.outputs.built }} + env: + RUNTIME_IMAGE_NAME: ${{ env.RUNTIME_IMAGE_NAME }} + steps: + - name: Download CI helpers + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + REF="${{ github.sha }}" + if [ -z "$REF" ]; then + REF="${{ github.ref_name }}" + fi + if [ -z "$REF" ]; then + REF="${{ inputs.branch || 'main' }}" + fi + URL="https://git.michaelschiemer.de/${{ github.repository }}/raw/${REF}/scripts/ci/clone_repo.sh" + mkdir -p /tmp/ci-tools + if [ -n "$CI_TOKEN" ]; then + curl -sfL -u "$CI_TOKEN:x-oauth-basic" "$URL" -o /tmp/ci-tools/clone_repo.sh + else + curl -sfL "$URL" -o /tmp/ci-tools/clone_repo.sh + fi + chmod +x /tmp/ci-tools/clone_repo.sh + + - name: Evaluate runtime build requirement + id: decision + shell: bash + run: | + if [ "${{ needs.changes.outputs.needs_runtime_build }}" = "true" ]; then + echo "Runtime base rebuild required" + echo "should_build=true" >> "$GITHUB_OUTPUT" + else + echo "Runtime base rebuild not required" + echo "should_build=false" >> "$GITHUB_OUTPUT" + fi + + - name: Install git and setup environment + if: steps.decision.outputs.should_build == 'true' + shell: sh + run: | + if ! command -v bash >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then + apk add --no-cache git bash curl + fi + bash --version + git --version + + - name: Checkout code + if: steps.decision.outputs.should_build == 'true' + shell: bash + run: | + REF_NAME="${{ github.ref_name }}" + INPUT_BRANCH="${{ inputs.branch }}" + REPO="${{ github.repository }}" + + export CI_REPOSITORY="$REPO" + export CI_TOKEN="${{ secrets.CI_TOKEN }}" + export CI_REF_NAME="$REF_NAME" + export CI_INPUT_BRANCH="$INPUT_BRANCH" + export CI_DEFAULT_BRANCH="main" + export CI_TARGET_DIR="/workspace/repo" + export CI_FETCH_DEPTH="1" + + /tmp/ci-tools/clone_repo.sh + + cd /workspace/repo + + - name: Setup Docker Buildx + if: steps.decision.outputs.should_build == 'true' + shell: bash + run: | + docker buildx version || echo "Buildx nicht gefunden" + if ! docker ps >/dev/null 2>&1; then + echo "❌ Fehler: Docker ist nicht verfügbar!" + exit 1 + fi + if ! docker buildx ls 2>/dev/null | grep -q builder; then + docker buildx create --name builder --use --driver docker-container + else + docker buildx use builder + fi + docker buildx inspect --bootstrap + + - name: Login to Registry + if: steps.decision.outputs.should_build == 'true' + shell: bash + run: | + REGISTRY_USER="${{ secrets.REGISTRY_USER }}" + REGISTRY_PASSWORD="${{ secrets.REGISTRY_PASSWORD }}" + REGISTRY_URL="${{ env.REGISTRY }}" + DEPLOYMENT_HOST="94.16.110.151" + + if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then + echo "❌ Error: Registry credentials missing" + exit 1 + fi + + echo "🔐 Logging in to registry..." + + HOST_IP=$(ip route | grep default | awk '{print $3}' 2>/dev/null | head -1 || echo "$DEPLOYMENT_HOST") + + REGISTRY_URLS=( + "registry.michaelschiemer.de" + "$REGISTRY_URL" + "$DEPLOYMENT_HOST" + "$DEPLOYMENT_HOST:5000" + "${HOST_IP}:5000" + ) + + LOGIN_SUCCESS=false + + for TEST_URL in "${REGISTRY_URLS[@]}"; do + echo "🔍 Testing registry: $TEST_URL" + + if [[ "$TEST_URL" == *":5000" ]]; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$TEST_URL/v2/" 2>&1 || echo "000") + + if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "200" ]; then + set +e + LOGIN_OUTPUT=$(echo "$REGISTRY_PASSWORD" | docker login "$TEST_URL" -u "$REGISTRY_USER" --password-stdin 2>&1) + LOGIN_EXIT_CODE=$? + set -e + + if [ $LOGIN_EXIT_CODE -eq 0 ]; then + REGISTRY_URL="$TEST_URL" + LOGIN_SUCCESS=true + break + fi + fi + else + HTTPS_CODE=$(curl -k -s -o /dev/null -w "%{http_code}" "https://$TEST_URL/v2/" 2>&1 || echo "000") + + if [ "$HTTPS_CODE" = "401" ] || [ "$HTTPS_CODE" = "200" ]; then + set +e + LOGIN_OUTPUT=$(echo "$REGISTRY_PASSWORD" | docker login "$TEST_URL" -u "$REGISTRY_USER" --password-stdin 2>&1) + LOGIN_EXIT_CODE=$? + set -e + + if [ $LOGIN_EXIT_CODE -eq 0 ]; then + REGISTRY_URL="$TEST_URL" + LOGIN_SUCCESS=true + break + fi + fi + fi + done + + if [ "$LOGIN_SUCCESS" = false ]; then + echo "❌ Registry login failed" + exit 1 + fi + + echo "✅ Registry login successful: $REGISTRY_URL" + echo "REGISTRY_URL=$REGISTRY_URL" >> $GITHUB_ENV + echo "CACHE_REGISTRY=${{ env.REGISTRY }}" >> $GITHUB_ENV + + - name: Build and push runtime base image + if: steps.decision.outputs.should_build == 'true' + shell: bash + env: + REGISTRY_URL: ${{ env.REGISTRY_URL }} + CACHE_REGISTRY: ${{ env.CACHE_REGISTRY }} + run: | + cd /workspace/repo + + TARGET_REGISTRY="$CACHE_REGISTRY" + if [ -z "$TARGET_REGISTRY" ]; then + TARGET_REGISTRY="$REGISTRY_URL" + fi + + echo "TARGET_REGISTRY=$TARGET_REGISTRY" >> $GITHUB_ENV + + IMAGE_NAME="${{ env.RUNTIME_IMAGE_NAME }}" + + echo "🏗️ Building runtime base image..." + echo " Registry: $TARGET_REGISTRY" + echo " Image: $IMAGE_NAME" + + docker buildx build \ + --platform linux/amd64 \ + --file ./Dockerfile.production \ + --target runtime-base \ + --tag "$TARGET_REGISTRY/$IMAGE_NAME:latest" \ + --tag "$TARGET_REGISTRY/$IMAGE_NAME:$(date +%Y%m%d)" \ + --push \ + . + + echo "✅ Runtime base image pushed successfully!" + + - name: Set runtime base outputs + id: set-result + shell: bash + run: | + if [ "${{ steps.decision.outputs.should_build }}" = "true" ]; then + TARGET_REGISTRY="${{ env.TARGET_REGISTRY || env.REGISTRY }}" + if [ -z "$TARGET_REGISTRY" ]; then + TARGET_REGISTRY="${{ env.REGISTRY }}" + fi + echo "image_ref=$TARGET_REGISTRY/$RUNTIME_IMAGE_NAME:latest" >> "$GITHUB_OUTPUT" + echo "built=true" >> "$GITHUB_OUTPUT" + else + echo "image_ref=" >> "$GITHUB_OUTPUT" + echo "built=false" >> "$GITHUB_OUTPUT" + fi + # Job 1: Run Tests test: + needs: changes + if: needs.changes.outputs.needs_build == 'true' name: Run Tests & Quality Checks runs-on: php-ci steps: + - name: Download CI helpers + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + REF="${{ github.sha }}" + if [ -z "$REF" ]; then + REF="${{ github.ref_name }}" + fi + if [ -z "$REF" ]; then + REF="${{ inputs.branch || 'main' }}" + fi + URL="https://git.michaelschiemer.de/${{ github.repository }}/raw/${REF}/scripts/ci/clone_repo.sh" + mkdir -p /tmp/ci-tools + if [ -n "$CI_TOKEN" ]; then + curl -sfL -u "$CI_TOKEN:x-oauth-basic" "$URL" -o /tmp/ci-tools/clone_repo.sh + else + curl -sfL "$URL" -o /tmp/ci-tools/clone_repo.sh + fi + chmod +x /tmp/ci-tools/clone_repo.sh + - name: Checkout code run: | - REF_NAME="${{ github.ref_name || inputs.branch || 'main' }}" + REF_NAME="${{ github.ref_name }}" + INPUT_BRANCH="${{ inputs.branch }}" REPO="${{ github.repository }}" - - if [ -n "${{ secrets.CI_TOKEN }}" ]; then - git clone --depth 1 --branch "$REF_NAME" \ - "https://${{ secrets.CI_TOKEN }}@git.michaelschiemer.de/${REPO}.git" \ - /workspace/repo - else - git clone --depth 1 --branch "$REF_NAME" \ - "https://git.michaelschiemer.de/${REPO}.git" \ - /workspace/repo || \ - git clone --depth 1 \ - "https://git.michaelschiemer.de/${REPO}.git" \ - /workspace/repo - fi - + + export CI_REPOSITORY="$REPO" + export CI_TOKEN="${{ secrets.CI_TOKEN }}" + export CI_REF_NAME="$REF_NAME" + export CI_INPUT_BRANCH="$INPUT_BRANCH" + export CI_DEFAULT_BRANCH="main" + export CI_TARGET_DIR="/workspace/repo" + export CI_FETCH_DEPTH="1" + + /tmp/ci-tools/clone_repo.sh + cd /workspace/repo - name: Cache Composer dependencies @@ -69,7 +467,8 @@ jobs: # Job 2: Build & Push Docker Image build: name: Build Docker Image - needs: test + needs: [changes, test, runtime-base] + if: needs.changes.outputs.needs_build == 'true' runs-on: docker-build outputs: image_tag: ${{ steps.image_info.outputs.IMAGE_TAG }} @@ -84,26 +483,46 @@ jobs: fi bash --version git --version - + + - name: Download CI helpers + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + REF="${{ github.sha }}" + if [ -z "$REF" ]; then + REF="${{ github.ref_name }}" + fi + if [ -z "$REF" ]; then + REF="${{ inputs.branch || 'main' }}" + fi + URL="https://git.michaelschiemer.de/${{ github.repository }}/raw/${REF}/scripts/ci/clone_repo.sh" + mkdir -p /tmp/ci-tools + if [ -n "$CI_TOKEN" ]; then + curl -sfL -u "$CI_TOKEN:x-oauth-basic" "$URL" -o /tmp/ci-tools/clone_repo.sh + else + curl -sfL "$URL" -o /tmp/ci-tools/clone_repo.sh + fi + chmod +x /tmp/ci-tools/clone_repo.sh + - name: Checkout code shell: bash run: | - REF_NAME="${{ github.ref_name || inputs.branch || 'main' }}" + REF_NAME="${{ github.ref_name }}" + INPUT_BRANCH="${{ inputs.branch }}" REPO="${{ github.repository }}" - - if [ -n "${{ secrets.CI_TOKEN }}" ]; then - git clone --depth 1 --branch "$REF_NAME" \ - "https://${{ secrets.CI_TOKEN }}@git.michaelschiemer.de/${REPO}.git" \ - /workspace/repo - else - git clone --depth 1 --branch "$REF_NAME" \ - "https://git.michaelschiemer.de/${REPO}.git" \ - /workspace/repo || \ - git clone --depth 1 \ - "https://git.michaelschiemer.de/${REPO}.git" \ - /workspace/repo - fi - + + export CI_REPOSITORY="$REPO" + export CI_TOKEN="${{ secrets.CI_TOKEN }}" + export CI_REF_NAME="$REF_NAME" + export CI_INPUT_BRANCH="$INPUT_BRANCH" + export CI_DEFAULT_BRANCH="main" + export CI_TARGET_DIR="/workspace/repo" + export CI_FETCH_DEPTH="1" + + /tmp/ci-tools/clone_repo.sh + cd /workspace/repo - name: Setup Docker Buildx @@ -151,6 +570,7 @@ jobs: REGISTRY_USER="${{ secrets.REGISTRY_USER }}" REGISTRY_PASSWORD="${{ secrets.REGISTRY_PASSWORD }}" REGISTRY_URL="${{ env.REGISTRY }}" + CANONICAL_REGISTRY="${{ env.REGISTRY }}" DEPLOYMENT_HOST="94.16.110.151" if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then @@ -215,15 +635,55 @@ jobs: echo "✅ Registry login successful: $REGISTRY_URL" echo "REGISTRY_URL=$REGISTRY_URL" >> $GITHUB_ENV + echo "CACHE_REGISTRY=$CANONICAL_REGISTRY" >> $GITHUB_ENV + + - name: Determine runtime base image + id: runtime-image + shell: bash + run: | + DEFAULT_IMAGE="${{ env.REGISTRY }}/${{ env.RUNTIME_IMAGE_NAME }}:latest" + SELECTED_IMAGE="$DEFAULT_IMAGE" + + if [ -n "${{ needs.runtime-base.outputs.image_ref }}" ]; then + SELECTED_IMAGE="${{ needs.runtime-base.outputs.image_ref }}" + else + if ! docker pull "$DEFAULT_IMAGE" >/dev/null 2>&1; then + ALT_REGISTRY="${{ env.REGISTRY_URL }}" + if [ -n "$ALT_REGISTRY" ] && [ "$ALT_REGISTRY" != "$DEFAULT_IMAGE" ]; then + ALT_IMAGE="$ALT_REGISTRY/${{ env.RUNTIME_IMAGE_NAME }}:latest" + if docker pull "$ALT_IMAGE" >/dev/null 2>&1; then + echo "ℹ️ Verwende alternatives Runtime-Image $ALT_IMAGE" + SELECTED_IMAGE="$ALT_IMAGE" + else + echo "⚠️ Runtime base image nicht verfügbar, verwende lokale Stage" + SELECTED_IMAGE="runtime-base" + fi + else + echo "⚠️ Runtime base image nicht verfügbar, verwende lokale Stage" + SELECTED_IMAGE="runtime-base" + fi + else + echo "ℹ️ Verwende bestehendes Runtime-Image $DEFAULT_IMAGE" + fi + fi + + echo "runtime_image=$SELECTED_IMAGE" >> "$GITHUB_OUTPUT" + echo "🏗️ Runtime-Image gewählt: $SELECTED_IMAGE" - name: Build and push Docker image shell: bash env: REGISTRY_URL: ${{ env.REGISTRY_URL }} + CACHE_REGISTRY: ${{ env.CACHE_REGISTRY }} + RUNTIME_IMAGE: ${{ steps.runtime-image.outputs.runtime_image }} run: | cd /workspace/repo REGISTRY_TO_USE="$REGISTRY_URL" + CACHE_TARGET="$CACHE_REGISTRY" + if [ -z "$CACHE_TARGET" ]; then + CACHE_TARGET="$REGISTRY_TO_USE" + fi IMAGE_NAME="${{ env.IMAGE_NAME }}" COMMIT_SHA="${{ github.sha }}" @@ -239,18 +699,23 @@ jobs: echo "🏗️ Building Docker image..." echo " Registry: $REGISTRY_TO_USE" + if [ "$CACHE_TARGET" != "$REGISTRY_TO_USE" ]; then + echo " Cache registry: $CACHE_TARGET" + fi + echo " Runtime base: $RUNTIME_IMAGE" echo " Image: $IMAGE_NAME" echo " Tags: latest, $TAG, git-$SHORT_SHA" docker buildx build \ --platform linux/amd64 \ --file ./Dockerfile.production \ + --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \ --tag "${REGISTRY_TO_USE}/${IMAGE_NAME}:latest" \ --tag "${REGISTRY_TO_USE}/${IMAGE_NAME}:${TAG}" \ --tag "${REGISTRY_TO_USE}/${IMAGE_NAME}:git-${SHORT_SHA}" \ - --cache-from type=registry,ref="${REGISTRY_TO_USE}/${IMAGE_NAME}:buildcache" \ + --cache-from type=registry,ref="${CACHE_TARGET}/${IMAGE_NAME}:buildcache" \ --cache-from type=registry,ref="${REGISTRY_TO_USE}/${IMAGE_NAME}:latest" \ - --cache-to type=registry,ref="${REGISTRY_TO_USE}/${IMAGE_NAME}:buildcache",mode=max \ + --cache-to type=registry,ref="${CACHE_TARGET}/${IMAGE_NAME}:buildcache",mode=max \ --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ --build-arg GIT_COMMIT=${COMMIT_SHA} \ --build-arg GIT_BRANCH=${REF_NAME} \ diff --git a/.gitea/workflows/cache-warm.yml b/.gitea/workflows/cache-warm.yml new file mode 100644 index 00000000..6db00fc9 --- /dev/null +++ b/.gitea/workflows/cache-warm.yml @@ -0,0 +1,227 @@ +name: Warm Docker Build Cache + +run-name: Warm Cache - ${{ inputs.branch || 'main' }} + +on: + schedule: + - cron: '0 4 * * 0' + workflow_dispatch: + inputs: + branch: + description: 'Branch to use for warming caches' + required: false + default: 'main' + +env: + REGISTRY: registry.michaelschiemer.de + IMAGE_NAME: framework + RUNTIME_IMAGE_NAME: framework-runtime + +jobs: + warm: + name: Refresh Buildx Caches + runs-on: docker-build + steps: + - name: Install git and tooling + shell: sh + run: | + if ! command -v bash >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then + apk add --no-cache git bash curl + fi + bash --version + git --version + + - name: Determine target ref + id: target + shell: bash + run: | + TARGET="${{ inputs.branch }}" + if [ -z "$TARGET" ]; then + TARGET="main" + fi + echo "target_ref=$TARGET" >> "$GITHUB_OUTPUT" + echo "TARGET_REF=$TARGET" >> $GITHUB_ENV + + - name: Download CI helpers + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + REF="${{ steps.target.outputs.target_ref }}" + URL="https://git.michaelschiemer.de/${{ github.repository }}/raw/${REF}/scripts/ci/clone_repo.sh" + mkdir -p /tmp/ci-tools + if [ -n "$CI_TOKEN" ]; then + curl -sfL -u "$CI_TOKEN:x-oauth-basic" "$URL" -o /tmp/ci-tools/clone_repo.sh + else + curl -sfL "$URL" -o /tmp/ci-tools/clone_repo.sh + fi + chmod +x /tmp/ci-tools/clone_repo.sh + + - name: Checkout repository + shell: bash + env: + TARGET_REF: ${{ steps.target.outputs.target_ref }} + run: | + export CI_REPOSITORY="${{ github.repository }}" + export CI_TOKEN="${{ secrets.CI_TOKEN }}" + export CI_REF_NAME="$TARGET_REF" + export CI_INPUT_BRANCH="$TARGET_REF" + export CI_DEFAULT_BRANCH="main" + export CI_TARGET_DIR="/workspace/repo" + export CI_FETCH_DEPTH="1" + /tmp/ci-tools/clone_repo.sh + cd /workspace/repo + git rev-parse HEAD + + - name: Setup Docker Buildx + shell: bash + run: | + docker buildx version || echo "Buildx nicht gefunden" + if ! docker buildx ls 2>/dev/null | grep -q builder; then + docker buildx create --name builder --use --driver docker-container + else + docker buildx use builder + fi + docker buildx inspect --bootstrap + + - name: Login to registry + id: login + shell: bash + run: | + REGISTRY_USER="${{ secrets.REGISTRY_USER }}" + REGISTRY_PASSWORD="${{ secrets.REGISTRY_PASSWORD }}" + REGISTRY_URL="${{ env.REGISTRY }}" + DEPLOYMENT_HOST="94.16.110.151" + + if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then + echo "❌ Error: Registry credentials missing" + exit 1 + fi + + echo "🔐 Logging in to registry..." + + HOST_IP=$(ip route | grep default | awk '{print $3}' 2>/dev/null | head -1 || echo "$DEPLOYMENT_HOST") + + REGISTRY_URLS=( + "registry.michaelschiemer.de" + "$REGISTRY_URL" + "$DEPLOYMENT_HOST" + "$DEPLOYMENT_HOST:5000" + "${HOST_IP}:5000" + ) + + LOGIN_SUCCESS=false + + for TEST_URL in "${REGISTRY_URLS[@]}"; do + echo "🔍 Testing registry: $TEST_URL" + + if [[ "$TEST_URL" == *":5000" ]]; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$TEST_URL/v2/" 2>&1 || echo "000") + + if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "200" ]; then + set +e + LOGIN_OUTPUT=$(echo "$REGISTRY_PASSWORD" | docker login "$TEST_URL" -u "$REGISTRY_USER" --password-stdin 2>&1) + LOGIN_EXIT_CODE=$? + set -e + + if [ $LOGIN_EXIT_CODE -eq 0 ]; then + REGISTRY_URL="$TEST_URL" + LOGIN_SUCCESS=true + break + fi + fi + else + HTTPS_CODE=$(curl -k -s -o /dev/null -w "%{http_code}" "https://$TEST_URL/v2/" 2>&1 || echo "000") + + if [ "$HTTPS_CODE" = "401" ] || [ "$HTTPS_CODE" = "200" ]; then + set +e + LOGIN_OUTPUT=$(echo "$REGISTRY_PASSWORD" | docker login "$TEST_URL" -u "$REGISTRY_USER" --password-stdin 2>&1) + LOGIN_EXIT_CODE=$? + set -e + + if [ $LOGIN_EXIT_CODE -eq 0 ]; then + REGISTRY_URL="$TEST_URL" + LOGIN_SUCCESS=true + break + fi + fi + fi + done + + if [ "$LOGIN_SUCCESS" = false ]; then + echo "❌ Registry login failed" + exit 1 + fi + + echo "✅ Registry login successful: $REGISTRY_URL" + echo "REGISTRY_URL=$REGISTRY_URL" >> $GITHUB_ENV + echo "CACHE_REGISTRY=${{ env.REGISTRY }}" >> $GITHUB_ENV + + - name: Warm runtime base cache + shell: bash + env: + REGISTRY_URL: ${{ env.REGISTRY_URL }} + CACHE_REGISTRY: ${{ env.CACHE_REGISTRY }} + run: | + cd /workspace/repo + + TARGET_REGISTRY="$CACHE_REGISTRY" + if [ -z "$TARGET_REGISTRY" ]; then + TARGET_REGISTRY="$REGISTRY_URL" + fi + + IMAGE_NAME="${{ env.RUNTIME_IMAGE_NAME }}" + DATE_TAG="warm-$(date -u +%Y%m%d%H%M)" + + docker buildx build \ + --platform linux/amd64 \ + --file ./Dockerfile.production \ + --target runtime-base \ + --build-arg RUNTIME_IMAGE=runtime-base \ + --cache-from type=registry,ref="$TARGET_REGISTRY/$IMAGE_NAME:buildcache" \ + --cache-to type=registry,ref="$TARGET_REGISTRY/$IMAGE_NAME:buildcache",mode=max \ + --tag "$TARGET_REGISTRY/$IMAGE_NAME:$DATE_TAG" \ + --push \ + . + + - name: Warm production cache + shell: bash + env: + REGISTRY_URL: ${{ env.REGISTRY_URL }} + CACHE_REGISTRY: ${{ env.CACHE_REGISTRY }} + run: | + cd /workspace/repo + + REGISTRY_TO_USE="$REGISTRY_URL" + CACHE_TARGET="$CACHE_REGISTRY" + if [ -z "$CACHE_TARGET" ]; then + CACHE_TARGET="$REGISTRY_TO_USE" + fi + + IMAGE_NAME="${{ env.IMAGE_NAME }}" + DATE_TAG="warm-$(date -u +%Y%m%d%H%M)" + + DEFAULT_RUNTIME="$CACHE_TARGET/${{ env.RUNTIME_IMAGE_NAME }}:latest" + RUNTIME_ARG="runtime-base" + if docker pull "$DEFAULT_RUNTIME" >/dev/null 2>&1; then + RUNTIME_ARG="$DEFAULT_RUNTIME" + fi + + docker buildx build \ + --platform linux/amd64 \ + --file ./Dockerfile.production \ + --build-arg RUNTIME_IMAGE="$RUNTIME_ARG" \ + --cache-from type=registry,ref="$CACHE_TARGET/$IMAGE_NAME:buildcache" \ + --cache-to type=registry,ref="$CACHE_TARGET/$IMAGE_NAME:buildcache",mode=max \ + --tag "$REGISTRY_TO_USE/$IMAGE_NAME:$DATE_TAG" \ + --push \ + . + + - name: Cleanup warm tags + shell: bash + env: + REGISTRY_URL: ${{ env.REGISTRY_URL }} + CACHE_REGISTRY: ${{ env.CACHE_REGISTRY }} + run: | + echo "ℹ️ Cleanup of warm tags deferred to registry retention policy" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..954f376d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,109 @@ +name: Continuous Integration + +run-name: CI Checks - ${{ github.ref_name || github.head_ref }} + +on: + push: + branches-ignore: + - main + - staging + paths-ignore: + - '**.md' + - 'docs/**' + pull_request: + branches: + - main + - staging + +env: + CACHE_DIR: /tmp/composer-cache + +jobs: + tests: + name: Run Tests & Quality Checks + runs-on: php-ci + steps: + - name: Download CI helpers + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + set -euo pipefail + REF="${{ github.sha }}" + if [ -z "$REF" ]; then + REF="${{ github.ref_name }}" + fi + if [ -z "$REF" ]; then + REF="${{ github.head_ref }}" + fi + if [ -z "$REF" ]; then + REF="${{ github.base_ref || 'develop' }}" + fi + URL="https://git.michaelschiemer.de/${{ github.repository }}/raw/${REF}/scripts/ci/clone_repo.sh" + mkdir -p /tmp/ci-tools + if [ -n "$CI_TOKEN" ]; then + curl -sfL -u "$CI_TOKEN:x-oauth-basic" "$URL" -o /tmp/ci-tools/clone_repo.sh + else + curl -sfL "$URL" -o /tmp/ci-tools/clone_repo.sh + fi + chmod +x /tmp/ci-tools/clone_repo.sh + + - name: Checkout code + env: + REF_NAME_GITHUB: ${{ github.ref_name }} + HEAD_REF: ${{ github.head_ref }} + BASE_REF: ${{ github.base_ref }} + run: | + REF_NAME="$REF_NAME_GITHUB" + if [ -z "$REF_NAME" ]; then + REF_NAME="$HEAD_REF" + fi + if [ -z "$REF_NAME" ]; then + REF_NAME="$BASE_REF" + fi + if [ -z "$REF_NAME" ]; then + REF_NAME="develop" + fi + + export CI_REPOSITORY="${{ github.repository }}" + export CI_TOKEN="${{ secrets.CI_TOKEN }}" + export CI_REF_NAME="$REF_NAME" + export CI_INPUT_BRANCH="$HEAD_REF" + export CI_DEFAULT_BRANCH="develop" + export CI_TARGET_DIR="/workspace/repo" + export CI_FETCH_DEPTH="1" + + /tmp/ci-tools/clone_repo.sh + + cd /workspace/repo + + - name: Restore Composer cache + run: | + if [ -d "$CACHE_DIR/vendor" ]; then + echo "📦 Restore composer dependencies" + cp -r "$CACHE_DIR/vendor" /workspace/repo/vendor || true + fi + + - name: Install PHP dependencies + run: | + cd /workspace/repo + composer install --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-req=php + + - name: Save Composer cache + run: | + mkdir -p "$CACHE_DIR" + cp -r /workspace/repo/vendor "$CACHE_DIR/vendor" || true + + - name: PHPStan (baseline) + run: | + cd /workspace/repo + make phpstan || echo "⚠️ phpstan skipped/failed" + + - name: Lint PHP (dry run) + run: | + cd /workspace/repo + make cs || echo "⚠️ php-cs-fixer dry run issues detected" + + - name: Tests temporarily skipped + run: | + echo "⚠️ Tests temporarily skipped due to PHP 8.5 compatibility issues" diff --git a/Dockerfile.production b/Dockerfile.production index fef43112..0bbb2a47 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -50,8 +50,8 @@ COPY tsconfig.json ./ # Build production assets RUN npm run build -# Stage: Production Runtime -FROM php:8.5.0RC3-fpm AS production +# Stage: Runtime Base Image (shared) +FROM php:8.5.0RC3-fpm AS runtime-base # Install system dependencies + nginx for production RUN apt-get update && apt-get install -y \ @@ -71,7 +71,7 @@ RUN apt-get update && apt-get install -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Install PHP extensions +# Install PHP extensions required at runtime RUN docker-php-ext-configure gd \ --with-freetype \ --with-jpeg \ @@ -95,13 +95,17 @@ RUN docker-php-ext-install -j$(nproc) \ RUN pecl install apcu redis-6.3.0RC1 \ && docker-php-ext-enable apcu redis -# Configure APCu +# Configure APCu defaults RUN echo "apc.enable_cli=1" >> /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ && echo "apc.shm_size=128M" >> /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini -# Install Composer +# Install Composer into the runtime image for artisan usage COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +# Stage: Production Runtime +ARG RUNTIME_IMAGE=runtime-base +FROM ${RUNTIME_IMAGE} AS production + # Set working directory WORKDIR /var/www/html @@ -128,7 +132,7 @@ RUN mkdir -p storage/logs storage/cache storage/uploads \ && chown -R www-data:www-data storage \ && chmod -R 775 storage -# Copy entrypoint script for production +# Copy entrypoint script for production (overrides runtime base when rebuilt) COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/scripts/ci/clone_repo.sh b/scripts/ci/clone_repo.sh new file mode 100755 index 00000000..cb76a148 --- /dev/null +++ b/scripts/ci/clone_repo.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET_DIR="${CI_TARGET_DIR:-/workspace/repo}" +REF_NAME="${CI_REF_NAME:-}" +INPUT_BRANCH="${CI_INPUT_BRANCH:-}" +DEFAULT_BRANCH="${CI_DEFAULT_BRANCH:-main}" +REPOSITORY="${CI_REPOSITORY:?CI_REPOSITORY is required}" +TOKEN="${CI_TOKEN:-}" +FETCH_DEPTH="${CI_FETCH_DEPTH:-1}" + +if [ -n "$REF_NAME" ]; then + SELECTED_REF="$REF_NAME" +elif [ -n "$INPUT_BRANCH" ]; then + SELECTED_REF="$INPUT_BRANCH" +else + SELECTED_REF="$DEFAULT_BRANCH" +fi + +PARENT_DIR="$(dirname "$TARGET_DIR")" +mkdir -p "$PARENT_DIR" +rm -rf "$TARGET_DIR" + +CLONE_URL="https://git.michaelschiemer.de/${REPOSITORY}.git" + +if [ -n "$TOKEN" ]; then + AUTH_URL="https://${TOKEN}@git.michaelschiemer.de/${REPOSITORY}.git" + git clone --depth "$FETCH_DEPTH" --branch "$SELECTED_REF" "$AUTH_URL" "$TARGET_DIR" +else + if ! git clone --depth "$FETCH_DEPTH" --branch "$SELECTED_REF" "$CLONE_URL" "$TARGET_DIR"; then + git clone --depth "$FETCH_DEPTH" "$CLONE_URL" "$TARGET_DIR" + ( + cd "$TARGET_DIR" + git checkout "$SELECTED_REF" || true + ) + fi +fi + +cd "$TARGET_DIR" +echo "Checked out $SELECTED_REF into $TARGET_DIR"