name: SonarQube Analysis on: pull_request: types: [opened, synchronize, reopened] concurrency: group: ${{ gitea.workflow }}-${{ gitea.ref }} cancel-in-progress: true jobs: sonarqube: name: Build, Test & Analyse runs-on: ubuntu-latest timeout-minutes: 15 env: SONAR_PROJECT_KEY: sonar-test-nest4 SONAR_ADMIN_TOKEN: ${{ secrets.SONAR_ADMIN_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} BACKSTAGE_TOKEN: ${{ secrets.BACKSTAGE_TOKEN }} BACKSTAGE_URL: https://dev-andrej.backstage.kyndemo.live steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Node.js run: | if ! command -v node &>/dev/null || [[ "$(node --version | cut -d. -f1 | tr -d 'v')" -lt 18 ]]; then curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt-get install -y --no-install-recommends nodejs fi node --version npm --version - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.npm key: typescript-nestjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: typescript-nestjs-${{ runner.os }}- - name: Cache SonarQube analysis data uses: actions/cache@v4 with: path: ~/.sonar/cache key: sonar-${{ runner.os }} restore-keys: sonar-${{ runner.os }}- # SONAR_TOKEN replaced by SONAR_ADMIN_TOKEN — the admin token is used # only for provisioning; the scan uses an ephemeral PROJECT_ANALYSIS_TOKEN # generated in the bootstrap step below. - name: Validate required secrets run: | [[ -n "$SONAR_ADMIN_TOKEN" ]] || { echo "::error::SONAR_ADMIN_TOKEN is not set"; exit 1; } [[ -n "$SONAR_HOST_URL" ]] || { echo "::error::SONAR_HOST_URL is not set"; exit 1; } [[ -n "$SONAR_PROJECT_KEY" ]] || { echo "::error::SONAR_PROJECT_KEY is not set"; exit 1; } SONAR_HOST_URL="${SONAR_HOST_URL%/}" AUTH_RESPONSE=$(curl -s -o /tmp/sonar-auth-response.json -w "%{http_code}" \ -u "${SONAR_ADMIN_TOKEN}:" \ "${SONAR_HOST_URL}/api/authentication/validate") if [[ "$AUTH_RESPONSE" != "200" ]]; then echo "::error::SonarQube is unreachable or returned HTTP ${AUTH_RESPONSE} — check SONAR_HOST_URL" exit 1 fi TOKEN_VALID=$(jq -r '.valid' /tmp/sonar-auth-response.json 2>/dev/null || echo "false") if [[ "$TOKEN_VALID" != "true" ]]; then echo "::error::SONAR_ADMIN_TOKEN is invalid or has been revoked (SonarQube returned valid=false)" exit 1 fi echo "✅ SONAR_ADMIN_TOKEN verified against ${SONAR_HOST_URL}" - name: Bootstrap SonarQube project and generate scan token id: sonar-bootstrap run: | # ---- 1. Create the project if it doesn't already exist ---- PROJECT_COUNT=$(curl -sf \ -u "${SONAR_ADMIN_TOKEN}:" \ "${SONAR_HOST_URL}/api/projects/search?projects=${SONAR_PROJECT_KEY}" \ | jq '.paging.total') if [[ "$PROJECT_COUNT" == "0" ]]; then echo "Project '${SONAR_PROJECT_KEY}' not found — creating it..." curl -sf -X POST \ -u "${SONAR_ADMIN_TOKEN}:" \ "${SONAR_HOST_URL}/api/projects/create" \ --data-urlencode "name=${SONAR_PROJECT_KEY}" \ --data-urlencode "project=${SONAR_PROJECT_KEY}" \ --data-urlencode "mainBranch=main" \ --data-urlencode "visibility=private" echo "✅ Project created." else echo "✅ Project '${SONAR_PROJECT_KEY}' already exists — skipping creation." fi # ---- 2. Revoke any leftover token from a previous failed run ---- TOKEN_NAME="gitea-scan-${SONAR_PROJECT_KEY}" curl -sf -X POST \ -u "${SONAR_ADMIN_TOKEN}:" \ "${SONAR_HOST_URL}/api/user_tokens/revoke" \ --data-urlencode "name=${TOKEN_NAME}" \ > /dev/null 2>&1 || true # ---- 3. Generate a fresh PROJECT_ANALYSIS_TOKEN for this run ---- SCAN_TOKEN=$(curl -sf -X POST \ -u "${SONAR_ADMIN_TOKEN}:" \ "${SONAR_HOST_URL}/api/user_tokens/generate" \ --data-urlencode "name=${TOKEN_NAME}" \ --data-urlencode "type=PROJECT_ANALYSIS_TOKEN" \ --data-urlencode "projectKey=${SONAR_PROJECT_KEY}" \ | jq -r '.token') [[ -n "$SCAN_TOKEN" ]] || { echo "::error::Failed to generate scan token"; exit 1; } echo "✅ Scan token generated for project '${SONAR_PROJECT_KEY}'" echo "::add-mask::${SCAN_TOKEN}" echo "SCAN_TOKEN=${SCAN_TOKEN}" >> "$GITHUB_ENV" - name: Install dependencies and test run: | npm install npm run test:cov - name: SonarQube analysis uses: sonarsource/sonarqube-scan-action@v5 env: SONAR_TOKEN: ${{ env.SCAN_TOKEN }} with: args: > -Dsonar.projectKey=sonar-test-nest4 -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info - name: Quality Gate check id: quality-gate run: | echo "Waiting for SonarQube to process the analysis..." for i in $(seq 1 24); do RESPONSE=$(curl -sf -u "${SCAN_TOKEN}:" \ "${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}" || true) STATUS=$(echo "$RESPONSE" | jq -r '.projectStatus.status' 2>/dev/null || echo "NONE") if [[ "$STATUS" =~ ^(OK|ERROR|WARN)$ ]]; then break; fi echo " Status: ${STATUS:-pending} — retrying in 5s..." sleep 5 done echo "" echo "══════════════════════════════════════════" echo " Quality Gate: $STATUS" echo "══════════════════════════════════════════" echo "$RESPONSE" | jq -r ' .projectStatus.conditions[] | if .status == "ERROR" then " ❌ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))" elif .status == "WARN" then " ⚠️ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))" else " ✅ \(.metricKey): \(.actualValue)" end' echo "══════════════════════════════════════════" echo "" FAILED=$(echo "$RESPONSE" | jq '[.projectStatus.conditions[] | select(.status == "ERROR")] | length') if [[ "$FAILED" -gt 0 ]]; then echo "::error::Quality Gate FAILED — $FAILED metric(s) did not meet threshold" exit 1 fi - name: Notify Backstage on quality gate failure if: always() && steps.quality-gate.outcome == 'failure' run: | echo "Sending quality gate failure notification to Backstage..." HTTP_CODE=$(curl -s -o /tmp/bs-notify-response.json -w "%{http_code}" \ -X POST "${BACKSTAGE_URL}/api/notifications" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${BACKSTAGE_TOKEN}" \ -d "{ \"recipients\": { \"type\": \"entity\", \"entityRef\": \"group:default/platform-engineering\" }, \"payload\": { \"title\": \"SonarQube Quality Gate Failed\", "description": "Quality gate failed for ${SONAR_PROJECT_KEY} on branch ${GITHUB_HEAD_REF}. Review the SonarQube dashboard for details.", \"link\": \"${SONAR_HOST_URL}/dashboard?id=${SONAR_PROJECT_KEY}\", \"severity\": \"high\", \"topic\": \"sonarqube-quality-gate\" } }") echo "HTTP status: ${HTTP_CODE}" cat /tmp/bs-notify-response.json 2>/dev/null || true if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then echo "✅ Backstage notification sent" else echo "⚠️ Backstage notification failed (HTTP ${HTTP_CODE})" fi - name: Revoke scan token if: always() run: | curl -sf -X POST \ -u "${SONAR_ADMIN_TOKEN}:" \ "${SONAR_HOST_URL}/api/user_tokens/revoke" \ --data-urlencode "name=gitea-scan-${SONAR_PROJECT_KEY}" \ && echo "✅ Scan token revoked" \ || echo "⚠️ Token revocation failed (may have already been removed)"