name: CI - Build & Verify run-name: CI ${{ gitea.ref_name }} by @${{ gitea.actor }} concurrency: group: vtubeawards-ci-${{ gitea.ref }} cancel-in-progress: true on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: inputs: no_cache: description: 'Build Docker images without cache' required: false default: false type: boolean jobs: verify: name: Build, Typecheck & Hygiene runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 - name: Reject tracked build output run: | set -euo pipefail tracked="$(git ls-files frontend/dist Backend/bin Backend/obj || true)" if [ -n "$tracked" ]; then echo "Build output is tracked and must be removed:" echo "$tracked" exit 1 fi - name: Reject tracked handoff artifacts run: | set -euo pipefail tracked="$(git ls-files '*.zip' '*.docx' 'prototype/**' 'update_v2_docx_*.py' || true)" if [ -n "$tracked" ]; then echo "Handoff/prototype artifacts are tracked and must be removed:" echo "$tracked" exit 1 fi - name: Secret pattern scan run: | set -euo pipefail hits="$( grep -RInE '(SECRET|TOKEN|PASSWORD|API_KEY)[[:space:]]*[:=][[:space:]]*.{8,}' \ --include='*.cs' \ --include='*.ts' \ --include='*.vue' \ Backend frontend/src 2>/dev/null || true )" if [ -n "$hits" ]; then echo "Possible hardcoded secret found:" echo "$hits" exit 1 fi - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '24' - name: Backend build run: | set -euo pipefail dotnet restore Backend/Backend.csproj dotnet build Backend/Backend.csproj --no-restore --configuration Release - name: Frontend build run: | set -euo pipefail base_version="$(node -p "require('./package.json').version")" run_number="${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}}" if [ "$run_number" != "0" ]; then export VITE_BUILD_VERSION="${base_version}+build.${run_number}" else export VITE_BUILD_VERSION="${base_version}+local" fi export VITE_BUILD_DATE="$(date -u +%Y-%m-%d)" echo "Building frontend version ${VITE_BUILD_VERSION}" npm ci npm run build working-directory: frontend deploy: name: Deploy to award.noveria.net runs-on: ubuntu-latest needs: verify timeout-minutes: 30 if: | gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/main') env: DEPLOY_PATH: /home/projekte_bao/vtuber-awards DEPLOY_APP_PATH: /home/projekte_bao/vtuber-awards/app DB_NETWORK: vtuber-awards_internal LIVE_URL: https://award.noveria.net steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Resolve deploy metadata id: meta run: | set -euo pipefail version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' frontend/package.json | head -n 1)" test -n "$version" run_number="${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}}" if [ "$run_number" != "0" ]; then build_version="${version}+build.${run_number}" else build_version="${version}+local" fi echo "sha=$(git rev-parse HEAD)" >> "$GITEA_OUTPUT" echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITEA_OUTPUT" echo "version=$version" >> "$GITEA_OUTPUT" echo "build_version=$build_version" >> "$GITEA_OUTPUT" echo "Deploying version ${build_version}" git log -1 --oneline - name: Verify production host layout run: | set -euo pipefail docker run --rm \ -v "${DEPLOY_PATH}:/deploy:ro" \ alpine:latest \ sh -lc ' test -f /deploy/compose.yaml test -f /deploy/deploy/backend.Dockerfile test -f /deploy/deploy/web.Dockerfile test -d /deploy/app ' - name: Sync code to production app directory run: | set -euo pipefail tar --exclude=.git -cf - . | docker run --rm -i \ -v "${DEPLOY_APP_PATH}:/dest" \ alpine:latest \ sh -lc ' set -e dest_owner="$(stat -c "%u:%g" /dest)" find /dest -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + tar -xf - -C /dest chown -R "$dest_owner" /dest ' - name: Write frontend build metadata run: | set -euo pipefail build_date="$(date -u +%Y-%m-%d)" docker run --rm \ -e BUILD_VERSION="${{ steps.meta.outputs.build_version }}" \ -e BUILD_DATE="${build_date}" \ -v "${DEPLOY_APP_PATH}:/app" \ alpine:latest \ sh -lc ' test -d /app/frontend printf "VITE_BUILD_VERSION=%s\nVITE_BUILD_DATE=%s\n" "$BUILD_VERSION" "$BUILD_DATE" > /app/frontend/.env.production echo "Frontend build metadata:" cat /app/frontend/.env.production ' - name: Prepare deploy host disk headroom run: | set -euo pipefail docker run --rm \ -v "${DEPLOY_PATH}:/workspace/vtube-awards" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace/vtube-awards \ docker:cli \ sh -lc ' set -e echo "Disk before cleanup:" df -h . mkdir -p backups old_backups="$(ls -1t backups/predeploy-*.dump 2>/dev/null | tail -n +4 || true)" if [ -n "$old_backups" ]; then echo "$old_backups" | while IFS= read -r backup; do [ -n "$backup" ] && rm -f "$backup" done fi docker builder prune -af || true docker image prune -af || true echo "Disk after cleanup:" df -h . ' - name: Build Docker images run: | set -euo pipefail no_cache="${{ gitea.event_name == 'workflow_dispatch' && inputs.no_cache || false }}" build_args="" if [ "$no_cache" = "true" ]; then build_args="--no-cache" fi docker run --rm \ -v "${DEPLOY_PATH}:/workspace/vtube-awards" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace/vtube-awards \ docker:cli \ sh -lc "docker compose config >/dev/null && docker compose build ${build_args} api web" - name: Backup database before migrations run: | set -euo pipefail docker run --rm \ -v "${DEPLOY_PATH}:/workspace/vtube-awards" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace/vtube-awards \ docker:cli \ sh -lc ' set -e mkdir -p backups stamp="$(date -u +%Y%m%dT%H%M%SZ)" backup="backups/predeploy-${{ steps.meta.outputs.short_sha }}-${stamp}.dump" docker compose exec -T postgres sh -lc '"'"'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc'"'"' > "$backup" test -s "$backup" find backups -name "predeploy-*.dump" -mtime +14 -delete echo "Database backup written to $backup" ' - name: Apply EF Core migrations run: | set -euo pipefail docker run --rm \ -e DEPLOY_APP_PATH="${DEPLOY_APP_PATH}" \ -e DB_NETWORK="${DB_NETWORK}" \ -v "${DEPLOY_PATH}:/workspace/vtube-awards" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace/vtube-awards \ docker:cli \ sh -lc ' set -e db="$(docker compose exec -T postgres printenv POSTGRES_DB)" user="$(docker compose exec -T postgres printenv POSTGRES_USER)" password="$(docker compose exec -T postgres printenv POSTGRES_PASSWORD)" docker run --rm \ --network "${DB_NETWORK}" \ -v "${DEPLOY_APP_PATH}/Backend:/src/Backend" \ -w /src/Backend \ -e "VTSA_POSTGRES=Host=postgres;Port=5432;Database=${db};Username=${user};Password=${password}" \ mcr.microsoft.com/dotnet/sdk:8.0-alpine \ sh -lc "dotnet tool install --global dotnet-ef >/dev/null && export PATH=\$PATH:/root/.dotnet/tools && dotnet restore >/dev/null && dotnet ef database update" ' - name: Restart production services run: | set -euo pipefail docker run --rm \ -v "${DEPLOY_PATH}:/workspace/vtube-awards" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace/vtube-awards \ docker:cli \ sh -lc 'docker compose up -d --wait --force-recreate api web' - name: Verify live health run: | set -euo pipefail retry() { label="$1" url="$2" attempts=10 wait=3 for attempt in $(seq 1 "$attempts"); do if body="$(curl -fsS --max-time 15 "$url")"; then echo "$label OK on attempt $attempt" echo "$body" return 0 fi echo "$label failed on attempt $attempt/$attempts; waiting ${wait}s" sleep "$wait" done echo "$label failed after $attempts attempts" return 1 } retry "API health" "${LIVE_URL}/api/health" db_body="$(curl -fsS --max-time 15 "${LIVE_URL}/api/health/database")" echo "$db_body" echo "$db_body" | grep -q '"canConnect":true' echo "$db_body" | grep -q '"pendingMigrations":\[\]' - name: Verify frontend assets run: | set -euo pipefail index="$(curl -fsS --max-time 15 "${LIVE_URL}/")" js_asset="$(printf "%s" "$index" | grep -Eo '/assets/index-[^"]+\.js' | head -n 1)" css_asset="$(printf "%s" "$index" | grep -Eo '/assets/index-[^"]+\.css' | head -n 1)" test -n "$js_asset" test -n "$css_asset" js_body="$(curl -fsS --max-time 20 "${LIVE_URL}${js_asset}")" printf "%s" "$js_body" | grep -Fq "${{ steps.meta.outputs.build_version }}" curl -fsS --max-time 20 "${LIVE_URL}${css_asset}" >/dev/null echo "Frontend assets served: ${js_asset}, ${css_asset}" echo "Frontend version verified: ${{ steps.meta.outputs.build_version }}" - name: Show production containers if: always() run: | docker run --rm \ -v "${DEPLOY_PATH}:/workspace/vtube-awards" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace/vtube-awards \ docker:cli \ sh -lc 'docker compose ps'