name: CI/CD on: push: branches: [main] pull_request: branches: [main] concurrency: group: meezi-cicd-${{ github.ref }} cancel-in-progress: true # ───────────────────────────────────────────────────────────────────────────── # HOW THIS WORKS # ───────────────────────────────────────────────────────────────────────────── # Runner labels (in gitea docker-compose): # ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers # self-hosted:host ← deploy runs directly on the server # # All images/packages served from Nexus at mirror.soroushasadi.com: # Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR) # NuGet → https://mirror.soroushasadi.com/repository/nuget-group/ # npm → https://mirror.soroushasadi.com/repository/npm-group/ # # Docker daemon: merge docker/daemon-registry-mirror.example.json into daemon.json # ───────────────────────────────────────────────────────────────────────────── jobs: api-build: name: "CI · API (dotnet build + test)" runs-on: ubuntu-latest container: image: mirror.soroushasadi.com/dotnet/sdk:10.0 options: >- --add-host=gitea:host-gateway services: postgres: image: mirror.soroushasadi.com/postgres:16-alpine env: POSTGRES_DB: meezi_test POSTGRES_USER: meezi POSTGRES_PASSWORD: meezi_test_pass options: >- --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 redis: image: mirror.soroushasadi.com/redis:7-alpine options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 10 steps: - name: Checkout env: TOKEN: ${{ github.token }} REF: ${{ github.ref }} run: | git init git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" git config http.extraheader "Authorization: Bearer ${TOKEN}" git fetch --depth=1 origin "${REF}" git checkout FETCH_HEAD - name: Write NuGet config run: | cat > /tmp/nuget.ci.config << 'EOF' EOF - name: Verify mirror TLS chain # The mirror's fullchain.pem now serves leaf → YR2 → ISRG Root YR # (cross-signed by ISRG Root X1, which IS in every stock trust store), # so no custom CA is needed. This step only sanity-checks the chain and # fails early with a clear message if the server cert regresses again. # POSIX sh only — the Gitea act runner v0.6.1 ignores shell: overrides. run: | set -eu echo | openssl s_client -connect mirror.soroushasadi.com:443 \ -servername mirror.soroushasadi.com 2>/dev/null \ | tee /tmp/sclient.txt | grep "Verify return code" || true if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then echo "❌ mirror.soroushasadi.com TLS chain is broken again." echo " Fix the cert ON THE SERVER (/etc/ssl/soroushasadi/fullchain.pem" echo " must include the full chain up to a publicly-trusted root)," echo " then: docker exec mirror-nginx nginx -s reload" exit 1 fi - name: Restore run: dotnet restore src/Meezi.API/Meezi.API.csproj --configfile /tmp/nuget.ci.config env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 NUGET_CERT_REVOCATION_MODE: offline - name: Build run: dotnet build src/Meezi.API/Meezi.API.csproj --no-restore -c Release - name: Test run: dotnet test --no-build -c Release --logger "console;verbosity=minimal" env: ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=meezi_test;Username=meezi;Password=meezi_test_pass" ConnectionStrings__Redis: "redis:6379" admin-api-build: name: "CI · Admin API (dotnet build)" runs-on: ubuntu-latest container: image: mirror.soroushasadi.com/dotnet/sdk:10.0 options: >- --add-host=gitea:host-gateway steps: - name: Checkout env: TOKEN: ${{ github.token }} REF: ${{ github.ref }} run: | git init git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" git config http.extraheader "Authorization: Bearer ${TOKEN}" git fetch --depth=1 origin "${REF}" git checkout FETCH_HEAD - name: Write NuGet config run: | cat > /tmp/nuget.ci.config << 'EOF' EOF - name: Verify mirror TLS chain # Same sanity check as api-build — see that job for full comments. run: | set -eu echo | openssl s_client -connect mirror.soroushasadi.com:443 \ -servername mirror.soroushasadi.com 2>/dev/null \ | tee /tmp/sclient.txt | grep "Verify return code" || true if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then echo "❌ mirror.soroushasadi.com TLS chain is broken again — fix the server cert." exit 1 fi - name: Restore run: dotnet restore src/Meezi.Admin.API/Meezi.Admin.API.csproj --configfile /tmp/nuget.ci.config env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 NUGET_CERT_REVOCATION_MODE: offline - name: Build run: dotnet build src/Meezi.Admin.API/Meezi.Admin.API.csproj --no-restore -c Release dashboard-check: name: "CI · Dashboard (tsc)" runs-on: ubuntu-latest container: image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: - name: Checkout env: TOKEN: ${{ github.token }} SHA: ${{ github.sha }} run: | wget -q \ --header "Authorization: Bearer ${TOKEN}" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/archive/${SHA}.tar.gz" \ -O /tmp/repo.tar.gz tar -xzf /tmp/repo.tar.gz --strip-components=1 rm -f /tmp/repo.tar.gz - name: Install dependencies working-directory: web/dashboard run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false - name: TypeScript check working-directory: web/dashboard run: npx tsc --noEmit env: NEXT_PUBLIC_API_URL: http://localhost:5080 admin-web-check: name: "CI · Admin Web (tsc)" runs-on: ubuntu-latest container: image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: - name: Checkout env: TOKEN: ${{ github.token }} SHA: ${{ github.sha }} run: | wget -q \ --header "Authorization: Bearer ${TOKEN}" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/archive/${SHA}.tar.gz" \ -O /tmp/repo.tar.gz tar -xzf /tmp/repo.tar.gz --strip-components=1 rm -f /tmp/repo.tar.gz - name: Install dependencies working-directory: web/admin run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false - name: TypeScript check working-directory: web/admin run: npx tsc --noEmit env: NEXT_PUBLIC_ADMIN_API_URL: http://localhost:5081 website-check: name: "CI · Website (tsc)" runs-on: ubuntu-latest container: image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: - name: Checkout env: TOKEN: ${{ github.token }} SHA: ${{ github.sha }} run: | wget -q \ --header "Authorization: Bearer ${TOKEN}" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/archive/${SHA}.tar.gz" \ -O /tmp/repo.tar.gz tar -xzf /tmp/repo.tar.gz --strip-components=1 rm -f /tmp/repo.tar.gz - name: Install dependencies working-directory: web/website run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false - name: TypeScript check working-directory: web/website run: npx tsc --noEmit env: MEEZI_API_URL: http://localhost:5080 koja-check: name: "CI · Koja (tsc)" runs-on: ubuntu-latest container: image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: - name: Checkout env: TOKEN: ${{ github.token }} SHA: ${{ github.sha }} run: | wget -q \ --header "Authorization: Bearer ${TOKEN}" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/archive/${SHA}.tar.gz" \ -O /tmp/repo.tar.gz tar -xzf /tmp/repo.tar.gz --strip-components=1 rm -f /tmp/repo.tar.gz - name: Install dependencies working-directory: web/koja run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false - name: TypeScript check working-directory: web/koja run: npx tsc --noEmit env: NEXT_PUBLIC_API_URL: http://localhost:5080 # ───────────────────────────────────────────────────────────────────────────── # DEPLOY — only on push to main, only if ALL CI jobs pass. # self-hosted:host — runs directly on your server where Docker is installed. # ───────────────────────────────────────────────────────────────────────────── deploy: name: "Deploy · all services" runs-on: self-hosted env: # act runner (host mode) starts with a minimal PATH — extend it so # docker (/usr/bin or /usr/local/bin) and snap packages are found. PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin needs: - api-build - admin-api-build - dashboard-check - admin-web-check - website-check - koja-check if: github.event_name == 'push' && github.ref == 'refs/heads/main' timeout-minutes: 40 steps: - name: Checkout env: TOKEN: ${{ github.token }} REF: ${{ github.ref }} run: | git init git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" git config http.extraheader "Authorization: Bearer ${TOKEN}" git fetch --depth=1 origin "${REF}" git checkout FETCH_HEAD - name: Write .env run: printf '%s' "$ENV_FILE" > .env env: ENV_FILE: ${{ secrets.ENV_FILE }} - name: Build main images (api, web, website, koja) run: docker compose build --parallel api web website koja env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 - name: Build admin images (admin-api, admin-web) run: | docker compose \ -f docker-compose.yml \ -f docker-compose.admin.yml \ build --parallel admin-api admin-web env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 - name: Stop old app containers # The existing containers were created before compose labels were added, # so Compose can't claim them and hits a name conflict on 'up'. # This step removes only meezi's own 6 app containers — never touches # postgres, redis, or any other project's containers. run: | for name in meezi-api meezi-web meezi-website meezi-koja meezi-admin-api meezi-admin-web; do docker stop "$name" 2>/dev/null || true docker rm "$name" 2>/dev/null || true done - name: Attach infrastructure to meezi network # postgres/redis may be on a different network (created before name:meezi # was in the compose file). Disconnect/reconnect with service-name aliases # so the API can resolve "Host=postgres" and "redis:6379". # App containers are stopped at this point so the brief disconnect is safe. run: | docker network inspect meezi_default >/dev/null 2>&1 \ || docker network create meezi_default docker network disconnect meezi_default meezi-db 2>/dev/null || true docker network disconnect meezi_default meezi-redis 2>/dev/null || true docker network connect --alias postgres meezi_default meezi-db docker network connect --alias redis meezi_default meezi-redis echo "=== infra network state ===" docker inspect meezi-db --format='meezi-db networks={{json .NetworkSettings.Networks}}' 2>&1 || true docker inspect meezi-redis --format='meezi-redis networks={{json .NetworkSettings.Networks}}' 2>&1 || true - name: Start API # --no-deps skips all depends_on checks so compose starts api immediately # without trying to verify postgres/redis health (they're not compose-managed). run: docker compose up -d --no-deps api - name: Wait for API healthy # Poll ourselves so we can detect crashes early and print logs before # restart-policy smothers them. Mirrors healthcheck: start_period=40s, # interval=10s, retries=12 → up to 3 min total. # Also checks RestartCount: restart:unless-stopped hides crashes behind # rapid restarts, so state=exited is fleeting — a rising count tells us. run: | echo "Waiting for meezi-api (up to 3 min)..." for i in $(seq 1 36); do HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing") STATE=$(docker inspect --format='{{.State.Status}}' meezi-api 2>/dev/null || echo "missing") RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-api 2>/dev/null || echo "0") echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS" [ "$HEALTH" = "healthy" ] && echo "✅ meezi-api healthy" && break if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then echo "❌ meezi-api crashed (state=$STATE) — logs:" docker logs meezi-api 2>&1 | tail -120 exit 1 fi if [ "$RESTARTS" -gt 1 ]; then echo "❌ meezi-api crash-loop (restarts=$RESTARTS) — logs:" docker logs meezi-api 2>&1 | tail -120 exit 1 fi [ "$i" = "36" ] && echo "❌ meezi-api timeout (3 min)" \ && docker logs meezi-api 2>&1 | tail -80 && exit 1 sleep 5 done - name: Start web services # API is healthy at this point; start the three Next.js frontends. run: docker compose up -d --no-deps web website koja - name: Start admin API run: | docker compose \ -f docker-compose.yml \ -f docker-compose.admin.yml \ up -d --no-deps admin-api - name: Wait for admin API healthy run: | echo "Waiting for meezi-admin-api (up to 3 min)..." for i in $(seq 1 36); do HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing") STATE=$(docker inspect --format='{{.State.Status}}' meezi-admin-api 2>/dev/null || echo "missing") RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-admin-api 2>/dev/null || echo "0") echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS" [ "$HEALTH" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then echo "❌ meezi-admin-api crashed (state=$STATE) — logs:" docker logs meezi-admin-api 2>&1 | tail -80 exit 1 fi if [ "$RESTARTS" -gt 1 ]; then echo "❌ meezi-admin-api crash-loop (restarts=$RESTARTS) — logs:" docker logs meezi-admin-api 2>&1 | tail -80 exit 1 fi [ "$i" = "36" ] && echo "❌ meezi-admin-api timeout (3 min)" \ && docker logs meezi-admin-api 2>&1 | tail -80 && exit 1 sleep 5 done - name: Start admin web run: | docker compose \ -f docker-compose.yml \ -f docker-compose.admin.yml \ up -d --no-deps admin-web - name: Show all running containers if: always() run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps - name: Dump logs on failure if: failure() run: | echo "=== meezi-api logs ===" docker logs meezi-api --tail=120 2>&1 || true echo "=== meezi-admin-api logs ===" docker logs meezi-admin-api --tail=80 2>&1 || true echo "=== meezi_default network ===" docker network inspect meezi_default 2>&1 || true echo "=== meezi-db network state ===" docker inspect meezi-db --format='{{json .NetworkSettings.Networks}}' 2>&1 || true echo "=== meezi-redis network state ===" docker inspect meezi-redis --format='{{json .NetworkSettings.Networks}}' 2>&1 || true - name: Prune dangling images if: success() run: | # Remove untagged () images left over from this and previous builds. # --filter dangling=true only removes images with no tags; never touches # other projects' named images (soroushasadi-site, drsousan, etc.). docker image prune -f echo "Disk after prune:" df -h /