Files
meezi/.gitea/workflows/ci-cd.yml
T
soroush.asadi 9a27858125 ci: trust Nexus mirror CA in backend dotnet restore (fixes skipped deploys)
The mirror's Let's Encrypt cert renewed under the new ISRG Root YR root,
which isn't in the dotnet SDK image's trust store. `dotnet restore` validates
TLS and fails (NU1301 / unable to get local issuer certificate), so both
backend CI jobs fail and the deploy is skipped. The npm jobs are unaffected
because they already pass --strict-ssl=false.

Pin the mirror's intermediate (CN=YR2, CA:TRUE, valid to Sept 2028) and add it
as a trust anchor before restore in:
- CI api-build + admin-api-build jobs (.gitea/workflows/ci-cd.yml)
- docker/api/Dockerfile + docker/admin-api/Dockerfile (deploy image builds)

Also set NUGET_CERT_REVOCATION_MODE=offline in the CI restore steps to avoid
CRL/OCSP fetches to lencr.org (filtered from Iran).

Permanent fix is server-side (re-chain to ISRG Root X1 or update trust stores);
this unblocks CI/deploys without depending on that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:22:57 +03:30

461 lines
18 KiB
YAML

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'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nexus"
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3"
/>
</packageSources>
</configuration>
EOF
- name: Trust Nexus mirror CA
# The mirror's Let's Encrypt cert renewed under the new ISRG Root YR, which is
# not yet in the SDK image's trust store. The npm jobs skip TLS via
# --strict-ssl=false; dotnet validates, so add the mirror's intermediate
# (CA:TRUE, valid to Sept 2028) as a trust anchor.
run: |
cp docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
update-ca-certificates
- 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'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nexus"
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3"
/>
</packageSources>
</configuration>
EOF
- name: Trust Nexus mirror CA
# See api-build: trust the mirror's intermediate so dotnet restore validates
# the new ISRG Root YR chain (npm jobs sidestep this with --strict-ssl=false).
run: |
cp docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
update-ca-certificates
- 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 (<none>) 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 /