diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..e184b01 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,84 @@ +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] + +jobs: + backend: + name: Backend (.NET) + runs-on: linux + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore Backend/Backend.csproj + + - name: Build + run: dotnet build Backend/Backend.csproj --no-restore --configuration Release + + frontend: + name: Frontend (Vue/TS) + runs-on: linux + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Build + run: npm run build + working-directory: frontend + + hygiene: + name: Repository Hygiene + runs-on: linux + 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: 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 diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..7788fa6 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,203 @@ +name: Deploy Production +run-name: Deploy VTubeAwards by @${{ gitea.actor }} + +concurrency: + group: vtubeawards-production + cancel-in-progress: false + +on: + workflow_run: + workflows: ["CI - Build & Verify"] + types: [completed] + branches: [main] + workflow_dispatch: + inputs: + git_ref: + description: 'Git ref to deploy' + required: false + default: 'main' + type: string + no_cache: + description: 'Build Docker images without cache' + required: false + default: false + type: boolean + +jobs: + deploy: + name: Deploy to award.noveria.net + runs-on: ubuntu-latest + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + !contains(github.event.workflow_run.head_commit.message, '[skip ci]')) + env: + DEPLOY_PATH: /home/projekte_bao/vtuber-awards + DEPLOY_APP_PATH: /home/projekte_bao/vtuber-awards/app + COMPOSE_PROJECT: vtuber-awards + DB_NETWORK: vtuber-awards_internal + LIVE_URL: https://award.noveria.net + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }} + fetch-depth: 0 + + - name: Resolve deploy metadata + id: meta + run: | + set -euo pipefail + echo "sha=$(git rev-parse HEAD)" >> "$GITEA_OUTPUT" + echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITEA_OUTPUT" + echo "Deploying $(git rev-parse --short HEAD)" + 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 + docker run --rm \ + -v "${{ gitea.workspace }}:/src:ro" \ + -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 {} + + cd /src + find . -mindepth 1 -maxdepth 1 ! -name .git -exec cp -R {} /dest/ \; + chown -R "$dest_owner" /dest + ' + + - name: Build Docker images + run: | + set -euo pipefail + no_cache="${{ github.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" + curl -fsS --max-time 20 "${LIVE_URL}${js_asset}" >/dev/null + curl -fsS --max-time 20 "${LIVE_URL}${css_asset}" >/dev/null + echo "Frontend assets served: ${js_asset}, ${css_asset}" + + - 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'