From 306deb1344433a72adeaeb38b03ec24e7cb15c27 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 5 Feb 2026 19:41:14 +0100 Subject: [PATCH] chore: align deployment pipeline with klz-2026 standards - Ported pagespeed-sitemap.ts and integrated @lhci/cli - Enriched deploy.yml with parallel QA, maintenance, and PageSpeed jobs - Refined environment detection (main/beta/rc/prod) - Consolidated workflows and cleaned up package.json --- .gitea/workflows/ci.yml | 39 ---- .gitea/workflows/deploy.yml | 319 ++++++++++++++++++++------ apps/web/package.json | 9 +- apps/web/scripts/pagespeed-sitemap.ts | 159 +++++++++++++ 4 files changed, 411 insertions(+), 115 deletions(-) delete mode 100644 .gitea/workflows/ci.yml create mode 100644 apps/web/scripts/pagespeed-sitemap.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index a13fdb0..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: ๐Ÿงช CI (QA) - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - qa: - name: ๐Ÿงช Quality Assurance - runs-on: docker - container: - image: catthehacker/ubuntu:act-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: ๐Ÿงช Run Checks - run: | - pnpm lint - pnpm build diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a00f1c3..bdd3217 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -7,12 +7,20 @@ on: tags: - 'v*' workflow_dispatch: + inputs: + skip_long_checks: + description: 'Skip tests? (true/false)' + required: false + default: 'false' concurrency: group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }} cancel-in-progress: true jobs: + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # JOB 1: Prepare & Determine Environment + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ prepare: name: ๐Ÿ” Prepare Environment runs-on: docker @@ -27,6 +35,103 @@ jobs: project_name: ${{ steps.determine.outputs.project_name }} is_prod: ${{ steps.determine.outputs.is_prod }} gotify_title: ${{ steps.determine.outputs.gotify_title }} + gotify_priority: ${{ steps.determine.outputs.gotify_priority }} + short_sha: ${{ steps.determine.outputs.short_sha }} + commit_msg: ${{ steps.determine.outputs.commit_msg }} + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: ๐Ÿงน Maintenance (High Density Cleanup) + shell: bash + run: | + echo "Purging old build layers and dangling images..." + docker image prune -f + docker builder prune -f --filter "until=6h" + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: ๐Ÿ” Environment & Version ermitteln + id: determine + shell: bash + run: | + TAG="${{ github.ref_name }}" + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9) + IMAGE_TAG="sha-${SHORT_SHA}" + COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available") + + if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then + TARGET="testing" + IMAGE_TAG="main-${SHORT_SHA}" + ENV_FILE=".env.testing" + TRAEFIK_HOST='`testing.mintel.me`' + NEXT_PUBLIC_BASE_URL="https://testing.mintel.me" + DIRECTUS_URL="https://cms.testing.mintel.me" + DIRECTUS_HOST='`cms.testing.mintel.me`' + PROJECT_NAME="mintel-me-testing" + IS_PROD="false" + GOTIFY_TITLE="๐Ÿงช Testing-Deploy" + GOTIFY_PRIORITY=4 + elif [[ "${{ github.ref_type }}" == "tag" ]]; then + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + TARGET="production" + IMAGE_TAG="$TAG" + ENV_FILE=".env.prod" + TRAEFIK_HOST='`mintel.me`, `www.mintel.me`' + NEXT_PUBLIC_BASE_URL="https://mintel.me" + DIRECTUS_URL="https://cms.mintel.me" + DIRECTUS_HOST='`cms.mintel.me`' + PROJECT_NAME="mintel-me-prod" + IS_PROD="true" + GOTIFY_TITLE="๐Ÿš€ Production-Release" + GOTIFY_PRIORITY=6 + elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then + TARGET="staging" + IMAGE_TAG="$TAG" + ENV_FILE=".env.staging" + TRAEFIK_HOST='`staging.mintel.me`' + NEXT_PUBLIC_BASE_URL="https://staging.mintel.me" + DIRECTUS_URL="https://cms.staging.mintel.me" + DIRECTUS_HOST='`cms.staging.mintel.me`' + PROJECT_NAME="mintel-me-staging" + IS_PROD="false" + GOTIFY_TITLE="๐Ÿงช Staging-Deploy (Pre-Release)" + GOTIFY_PRIORITY=5 + else + TARGET="skip" + GOTIFY_TITLE="โ“ Unbekannter Tag" + GOTIFY_PRIORITY=3 + fi + else + TARGET="skip" + fi + + { + echo "target=$TARGET" + echo "image_tag=$IMAGE_TAG" + echo "env_file=$ENV_FILE" + echo "traefik_host=$TRAEFIK_HOST" + echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" + echo "directus_url=$DIRECTUS_URL" + echo "directus_host=$DIRECTUS_HOST" + echo "project_name=$PROJECT_NAME" + echo "is_prod=$IS_PROD" + echo "gotify_title=$GOTIFY_TITLE" + echo "gotify_priority=$GOTIFY_PRIORITY" + echo "short_sha=$SHORT_SHA" + echo "commit_msg=$COMMIT_MSG" + } >> "$GITHUB_OUTPUT" + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # JOB 2: Quality Assurance (pnpm Lint & Test) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + qa: + name: ๐Ÿงช Quality Assurance + needs: prepare + if: needs.prepare.outputs.target != 'skip' + runs-on: docker container: image: catthehacker/ubuntu:act-latest steps: @@ -35,73 +140,50 @@ jobs: with: fetch-depth: 1 - - name: ๐Ÿ” Determine Environment - id: determine - shell: bash + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: ๐Ÿงช Run Checks in Parallel + if: github.event.inputs.skip_long_checks != 'true' run: | - TAG="${{ github.ref_name }}" - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9) - - if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then - TARGET="testing" - IMAGE_TAG="main-${SHORT_SHA}" - ENV_FILE=".env.testing" - TRAEFIK_HOST="testing.mintel.me" - NEXT_PUBLIC_BASE_URL="https://testing.mintel.me" - DIRECTUS_URL="https://cms.testing.mintel.me" - DIRECTUS_HOST="cms.testing.mintel.me" - PROJECT_NAME="mintel-me-testing" - IS_PROD="false" - GOTIFY_TITLE="๐Ÿงช Testing-Deploy" - elif [[ "${{ github.ref_type }}" == "tag" ]]; then - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - TARGET="production" - IMAGE_TAG="$TAG" - ENV_FILE=".env.prod" - TRAEFIK_HOST="mintel.me, www.mintel.me" - NEXT_PUBLIC_BASE_URL="https://mintel.me" - DIRECTUS_URL="https://cms.mintel.me" - DIRECTUS_HOST="cms.mintel.me" - PROJECT_NAME="mintel-me-prod" - IS_PROD="true" - GOTIFY_TITLE="๐Ÿš€ Production-Release" - else - TARGET="staging" - IMAGE_TAG="$TAG" - ENV_FILE=".env.staging" - TRAEFIK_HOST="staging.mintel.me" - NEXT_PUBLIC_BASE_URL="https://staging.mintel.me" - DIRECTUS_URL="https://cms.staging.mintel.me" - DIRECTUS_HOST="cms.staging.mintel.me" - PROJECT_NAME="mintel-me-staging" - IS_PROD="false" - GOTIFY_TITLE="๐Ÿงช Staging-Deploy" - fi - else - echo "Skipping deploy for unknown ref type" - exit 1 - fi + pnpm lint & + LINT_PID=$! + pnpm --filter @mintel/web typecheck & + TYPE_PID=$! + # pnpm test & + # TEST_PID=$! - echo "target=$TARGET" >> $GITHUB_OUTPUT - echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT - echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT - echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT - echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT - echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT - echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT - echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT - echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT - echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT + wait $LINT_PID || exit 1 + wait $TYPE_PID || exit 1 + # wait $TEST_PID || exit 1 + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # JOB 3: Build & Push Docker Image + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ build: - name: ๐Ÿ—๏ธ Build & Push + name: ๐Ÿ—๏ธ Build App needs: prepare + if: ${{ needs.prepare.outputs.target != 'skip' }} runs-on: docker container: image: catthehacker/ubuntu:act-latest steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: ๐Ÿณ Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -110,12 +192,13 @@ jobs: run: | echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin - - name: ๐Ÿ—๏ธ Build & Push + - name: ๐Ÿ—๏ธ App bauen & pushen env: IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} TARGET: ${{ needs.prepare.outputs.target }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | docker buildx build \ --pull \ @@ -125,27 +208,33 @@ jobs: --build-arg DIRECTUS_URL="$DIRECTUS_URL" \ --secret id=NPM_TOKEN,env=NPM_TOKEN \ -t registry.infra.mintel.me/mintel/mintel.me:$IMAGE_TAG \ + --cache-from type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache \ + --cache-to type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache,mode=max \ --push . - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # JOB 4: Deploy via SSH + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ deploy: name: ๐Ÿš€ Deploy - needs: [prepare, build] + needs: [prepare, build, qa] + if: ${{ needs.prepare.outputs.target != 'skip' }} runs-on: docker container: image: catthehacker/ubuntu:act-latest + env: + TARGET: ${{ needs.prepare.outputs.target }} + IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + ENV_FILE: ${{ needs.prepare.outputs.env_file }} + PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: ๐Ÿš€ Deploy via SSH - env: - TARGET: ${{ needs.prepare.outputs.target }} - IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} - ENV_FILE: ${{ needs.prepare.outputs.env_file }} - PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} - AUTH_MIDDLEWARE: ${{ needs.prepare.outputs.is_prod == 'true' && 'compress' || format('{0}-auth,compress', needs.prepare.outputs.project_name) }} + shell: bash run: | mkdir -p ~/.ssh echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519 @@ -153,8 +242,8 @@ jobs: ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null # Create .env on the fly - cat > .env.tmp << EOF - # Generated by CI + cat > /tmp/mintel.me.env << EOF + # Generated by CI - $TARGET - $(date -u) NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} NEXT_PUBLIC_TARGET=$TARGET DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} @@ -162,7 +251,7 @@ jobs: TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }} IMAGE_TAG=$IMAGE_TAG PROJECT_NAME=$PROJECT_NAME - AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE + AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" ) # Secrets DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY }} @@ -170,23 +259,106 @@ jobs: DIRECTUS_DB_NAME=${{ secrets.DIRECTUS_DB_NAME || 'directus' }} DIRECTUS_DB_USER=${{ secrets.DIRECTUS_DB_USER || 'directus' }} DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD }} + + # General + NODE_ENV=production EOF - scp .env.tmp root@alpha.mintel.me:/home/deploy/sites/mintel.me/$ENV_FILE + # 1. Cleanup and Create Directories on server BEFORE SCP + ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF' + set -e + mkdir -p /home/deploy/sites/mintel.me/varnish + mkdir -p /home/deploy/sites/mintel.me/directus/uploads /home/deploy/sites/mintel.me/directus/extensions + if [ -d "/home/deploy/sites/mintel.me/varnish/default.vcl" ]; then + echo "๐Ÿงน Removing directory 'varnish/default.vcl' created by Docker..." + rm -rf /home/deploy/sites/mintel.me/varnish/default.vcl + fi + chown -R deploy:deploy /home/deploy/sites/mintel.me/directus /home/deploy/sites/mintel.me/varnish + EOF + + # 2. Transfer files + scp /tmp/mintel.me.env root@alpha.mintel.me:/home/deploy/sites/mintel.me/$ENV_FILE scp docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/mintel.me/docker-compose.yml scp -r varnish root@alpha.mintel.me:/home/deploy/sites/mintel.me/ ssh root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF' + set -e cd /home/deploy/sites/mintel.me echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans docker system prune -f --filter "until=24h" + echo "โ†’ Waiting 15s for warmup..." + sleep 15 + docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps EOF + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # JOB 5: PageSpeed Test + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + pagespeed: + name: โšก PageSpeed + needs: [prepare, deploy] + if: | + always() && + needs.prepare.outputs.target != 'skip' && + needs.deploy.result == 'success' && + github.event.inputs.skip_long_checks != 'true' + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: ๐Ÿ” Install Chromium (ARM64) + run: | + apt-get update + apt-get install -y gnupg wget ca-certificates + CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME) + + mkdir -p /etc/apt/keyrings + KEY_ID="82BB6851C64F6880" + wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg + echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list + printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb + + apt-get update + apt-get install -y --allow-downgrades chromium + [ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome + continue-on-error: true + + - name: ๐Ÿงช Run PageSpeed (Lighthouse) + env: + NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} + PAGESPEED_LIMIT: 8 + CHROME_PATH: /usr/bin/chromium + run: pnpm --filter @mintel/web run pagespeed:test + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # JOB 6: Notifications + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ notifications: name: ๐Ÿ”” Notifications - needs: [prepare, deploy] + needs: [prepare, qa, build, deploy, pagespeed] if: always() runs-on: docker container: @@ -195,7 +367,8 @@ jobs: - name: ๐Ÿ”” Gotify run: | STATUS="${{ needs.deploy.result == 'success' && 'โœ…' || 'โŒ' }}" + PRIORITY="${{ needs.deploy.result == 'success' && needs.prepare.outputs.gotify_priority || '8' }}" curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ -F "title=$STATUS ${{ needs.prepare.outputs.gotify_title }}" \ - -F "message=Deploy to **${{ needs.prepare.outputs.target }}** ${{ needs.deploy.result }}.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \ - -F "priority=5" || true + -F "message=Deploy to **${{ needs.prepare.outputs.target }}** ${{ needs.deploy.result }}.\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nActor: ${{ github.actor }}" \ + -F "priority=$PRIORITY" || true diff --git a/apps/web/package.json b/apps/web/package.json index f52a012..48a611a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,8 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "npm run test:smoke", - "test:smoke": "tsx ./scripts/smoke-test.ts", + "test": "npm run test:links", "test:links": "tsx ./scripts/test-links.ts", "test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts", "clone-website": "tsx ./scripts/clone-recursive.ts", @@ -20,7 +19,9 @@ "video:render": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4", "video:render:contact": "remotion render video/index.ts ContactFormShowcase out/contact-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite", "video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite", - "video:render:all": "npm run video:render:contact && npm run video:render:button" + "video:render:all": "npm run video:render:contact && npm run video:render:button", + "pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { "@mintel/next-utils": "^1.0.1", @@ -67,6 +68,8 @@ "@types/qrcode": "^1.5.6", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", + "@lhci/cli": "^0.15.1", + "cheerio": "^1.1.2", "tsx": "^4.21.0", "typescript": "5.9.3" } diff --git a/apps/web/scripts/pagespeed-sitemap.ts b/apps/web/scripts/pagespeed-sitemap.ts new file mode 100644 index 0000000..4000be4 --- /dev/null +++ b/apps/web/scripts/pagespeed-sitemap.ts @@ -0,0 +1,159 @@ +import axios from 'axios'; +import * as cheerio from 'cheerio'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * PageSpeed Test Script + * + * 1. Fetches sitemap.xml from the target URL + * 2. Extracts all URLs + * 3. Runs Lighthouse CI on those URLs + */ + +const targetUrl = + process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'https://testing.klz-cables.com'; +const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs +const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; + +async function main() { + console.log(`\n๐Ÿš€ Starting PageSpeed test for: ${targetUrl}`); + console.log(`๐Ÿ“Š Limit: ${limit} pages\n`); + + try { + // 1. Fetch Sitemap + const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`; + console.log(`๐Ÿ“ฅ Fetching sitemap from ${sitemapUrl}...`); + + // We might need to bypass gatekeeper for the sitemap fetch too + const response = await axios.get(sitemapUrl, { + headers: { + Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, + }, + validateStatus: (status) => status < 400, + }); + + const $ = cheerio.load(response.data, { xmlMode: true }); + let urls = $('url loc') + .map((i, el) => $(el).text()) + .get(); + + // Cleanup, filter and normalize domains to targetUrl + const urlPattern = /https?:\/\/[^\/]+/; + urls = [...new Set(urls)] + .filter((u) => u.startsWith('http')) + .map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, ''))) + .sort(); + + console.log(`โœ… Found ${urls.length} URLs in sitemap.`); + + if (urls.length === 0) { + console.error('โŒ No URLs found in sitemap. Is the site up?'); + process.exit(1); + } + + if (urls.length > limit) { + console.log( + `โš ๏ธ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`, + ); + // Try to pick a variety: home, some products, some blog posts + const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl); + const others = urls.filter((u) => !home.includes(u)); + urls = [...home, ...others.slice(0, limit - home.length)]; + } + + console.log(`๐Ÿงช Pages to be tested:`); + urls.forEach((u) => console.log(` - ${u}`)); + + // 2. Prepare LHCI command + // We use --collect.url multiple times + const urlArgs = urls.map((u) => `--collect.url="${u}"`).join(' '); + + // Handle authentication for staging/testing + // Lighthouse can set cookies via --collect.settings.extraHeaders + const extraHeaders = JSON.stringify({ + Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, + }); + + const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH; + const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : ''; + + // Clean up old reports + if (fs.existsSync('.lighthouseci')) { + fs.rmSync('.lighthouseci', { recursive: true, force: true }); + } + + // Using a more robust way to execute and capture output + // We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports + const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert`; + + console.log(`๐Ÿ’ป Executing LHCI...`); + + try { + execSync(lhciCommand, { + encoding: 'utf8', + stdio: 'inherit', + }); + } catch (err: any) { + console.warn('โš ๏ธ LHCI assertion finished with warnings or errors.'); + // We continue to show the table even if assertions failed + } + + // 3. Summarize Results (Local & Independent) + const manifestPath = path.join(process.cwd(), '.lighthouseci', 'manifest.json'); + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + console.log(`\n๐Ÿ“Š PageSpeed Summary (FOSS - Local Report):\n`); + + const summaryTable = manifest.map((entry: any) => { + const s = entry.summary; + return { + URL: entry.url.replace(targetUrl, ''), + Perf: Math.round(s.performance * 100), + Acc: Math.round(s.accessibility * 100), + BP: Math.round(s['best-practices'] * 100), + SEO: Math.round(s.seo * 100), + }; + }); + + console.table(summaryTable); + + // Calculate Average + const avg = { + Perf: Math.round( + summaryTable.reduce((acc: any, curr: any) => acc + curr.Perf, 0) / summaryTable.length, + ), + Acc: Math.round( + summaryTable.reduce((acc: any, curr: any) => acc + curr.Acc, 0) / summaryTable.length, + ), + BP: Math.round( + summaryTable.reduce((acc: any, curr: any) => acc + curr.BP, 0) / summaryTable.length, + ), + SEO: Math.round( + summaryTable.reduce((acc: any, curr: any) => acc + curr.SEO, 0) / summaryTable.length, + ), + }; + + console.log(`\n๐Ÿ“ˆ Average Scores:`); + console.log(` Performance: ${avg.Perf > 90 ? 'โœ…' : 'โš ๏ธ'} ${avg.Perf}`); + console.log(` Accessibility: ${avg.Acc > 90 ? 'โœ…' : 'โš ๏ธ'} ${avg.Acc}`); + console.log(` Best Practices: ${avg.BP > 90 ? 'โœ…' : 'โš ๏ธ'} ${avg.BP}`); + console.log(` SEO: ${avg.SEO > 90 ? 'โœ…' : 'โš ๏ธ'} ${avg.SEO}`); + } + + console.log(`\nโœจ PageSpeed tests completed successfully!`); + } catch (error: any) { + console.error(`\nโŒ Error during PageSpeed test:`); + if (axios.isAxiosError(error)) { + console.error(`Status: ${error.response?.status}`); + console.error(`StatusText: ${error.response?.statusText}`); + console.error(`URL: ${error.config?.url}`); + } else { + console.error(error.message); + } + process.exit(1); + } +} + +main();