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: demo-mayo-1 SONAR_ADMIN_TOKEN: ${{ secrets.SONAR_ADMIN_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} BACKSTAGE_CLEANUP_TOKEN: ${{ secrets.BACKSTAGE_CLEANUP_TOKEN }} BACKSTAGE_URL: https://backstage.dev.crucible.kyndemo.live steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 # This factory renovates arbitrary applications, so the analysis cannot assume one of # them. Everything below used to be Maven unconditionally, and the very first Java step # was `chmod +x mvnw` -- which on a Node repository fails with # "cannot access 'mvnw': No such file or directory" and takes the whole run with it. # # Two paths, chosen from what is actually in the repository: # # maven pom.xml present -> mvnw verify + sonar-maven-plugin, with JaCoCo # cli anything else -> sonar-scanner CLI, which analyses JS/TS, Python, Go # and more without needing that language's build # # `cli` rather than `node` because the fallback is genuinely language-agnostic: a Python # or Go repository gets analysed too, just without coverage. Only Node adds a test step, # because it is the one runtime whose coverage format the scanner reads for free. - name: Detect project type run: | set -euo pipefail if [ -f pom.xml ]; then KIND=maven else KIND=cli fi echo "PROJECT_KIND=${KIND}" >> $GITHUB_ENV echo "Detected project kind: ${KIND}" if [ "${KIND}" = "cli" ] && [ -f package.json ]; then echo "HAS_NPM=true" >> $GITHUB_ENV echo "package.json present — will run tests for coverage." fi # Deliberately NOT gated on PROJECT_KIND. Maven needs it to build, and sonar-scanner is # itself a Java program that ships no JRE -- so on the CLI path a Node image without # java would fail inside the scanner's wrapper script with a bare "java: not found". # Installing it once here is cheaper than diagnosing that. - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Make Maven wrapper executable if: env.PROJECT_KIND == 'maven' run: chmod +x mvnw - name: Cache Maven packages if: env.PROJECT_KIND == 'maven' uses: actions/cache@v4 with: path: ~/.m2/repository key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }} restore-keys: maven-${{ runner.os }}- # The sonar-maven-plugin caches here. The CLI scanner keeps its own cache under # ~/.sonar too, so this is useful on both paths -- but the key must not be keyed on # pom.xml, which does not exist on the CLI path and would collapse every non-Maven # repository onto one shared key. - name: Cache SonarQube analysis data uses: actions/cache@v4 with: path: ~/.sonar/cache key: sonar-${{ runner.os }}-${{ hashFiles('**/pom.xml', '**/package-lock.json', '**/go.sum', '**/requirements.txt') }} restore-keys: sonar-${{ runner.os }}- - 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: | 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 # The token name MUST be unique per run. It used to be `gitea-scan-`, # shared by every job analysing this project, and each job both revokes that name # before generating and revokes it again on cleanup. Concurrent jobs therefore # destroyed each other's credentials and every analysis failed with # `Not authorized. Please check the user token in 'sonar.token'` -- an error that # reads like a permissions problem and is not one. Observed on reno-rehearsal-6, # where three overlapping jobs failed in a chain: # # 18:37:39 job A generates the shared name # 18:37:40 job B revokes it, generates its own # 18:38:16 job C revokes it, generates its own # 18:38:53 job A analysis -> Not authorized (killed at 18:37:40) # 18:38:53 job A cleanup revokes the name -> kills job C's live token # 18:39:26 job C analysis -> Not authorized (killed at 18:38:53) # # The `concurrency:` block above does not prevent this: Gitea Actions does not # honour it, so the overlap is real no matter what the workflow declares. Scoping # the name to the run id removes the shared resource instead of relying on # serialisation, and the cleanup step revokes that same per-run name so tokens # still do not accumulate. TOKEN_NAME="gitea-scan-${SONAR_PROJECT_KEY}-${GITHUB_RUN_ID}" echo "TOKEN_NAME=${TOKEN_NAME}" >> "$GITHUB_ENV" 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 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') # `jq -r` prints the string "null" for a missing key, which is non-empty and would # sail past a bare -n test, then fail much later as an authorisation error. if [[ -z "$SCAN_TOKEN" || "$SCAN_TOKEN" == "null" ]]; then echo "::error::Failed to generate scan token '${TOKEN_NAME}'"; exit 1 fi echo "✅ Scan token generated for project '${SONAR_PROJECT_KEY}'" echo "::add-mask::${SCAN_TOKEN}" echo "SCAN_TOKEN=${SCAN_TOKEN}" >> "$GITHUB_ENV" # Spring projects carry io.spring.nohttp, a Checkstyle rule that fails the build on any # `http://` URL anywhere under ${basedir}. The platform artifacts this factory adds # contain one legitimately -- the in-cluster OTLP collector, which is plaintext by # design and has no https:// form -- so the app's own quality gate rejects the # modernisation: # # [ERROR] score.yaml:[7,37] (extension) NoHttp: http:// URLs are not allowed # [ERROR] overlays/otel/kustomization.yaml:[13,81] ... # [ERROR] overlays/otel/patches/otel-patch.yaml:[17,19] ... # # The two -D properties on the build below cannot fix it: the SuppressionFilter in # nohttp-checkstyle.xml reads a FIXED path, ${config_loc}/nohttp-checkstyle-suppressions.xml, # so the only lever is the content of that file. Hence editing it rather than passing # a flag. # # Scoped deliberately: `checks="NoHttp"` only, and only the paths this platform owns. # Every other Checkstyle rule still applies to them, and NoHttp still applies to all # application source -- which is the rule's actual purpose. - name: Allowlist platform artifacts for nohttp if: env.PROJECT_KIND == 'maven' run: | set -euo pipefail SUPPRESSIONS=src/checkstyle/nohttp-checkstyle-suppressions.xml if [ ! -f "$SUPPRESSIONS" ]; then echo "No nohttp suppressions file — project does not use nohttp, nothing to do." exit 0 fi if grep -q 'crucible-platform-artifacts' "$SUPPRESSIONS"; then echo "Platform artifacts already allowlisted." exit 0 fi grep -q '' "$SUPPRESSIONS" || { echo "::error::$SUPPRESSIONS has no closing tag"; exit 1; } # awk, not python3 or sed -i: this job runs in whatever image the app's build needs, # so only POSIX tooling is safe to assume. awk ' /<\/suppressions>/ && !inserted { print "\t"; print "\t"; print "\t"; print "\t"; print "\t"; print "\t"; print "\t"; inserted = 1 } { print } ' "$SUPPRESSIONS" > "$SUPPRESSIONS.new" && mv "$SUPPRESSIONS.new" "$SUPPRESSIONS" echo "Allowlisted platform artifacts for NoHttp:" cat "$SUPPRESSIONS" - name: Build and test if: env.PROJECT_KIND == 'maven' run: | ./mvnw -B verify \ -Dtest='!PostgresIntegrationTests,!MySqlIntegrationTests' \ -Dnohttp.checkstyle.suppressions.file=src/checkstyle/nohttp-checkstyle-suppressions.xml \ -Dcheckstyle.suppressionsFile=src/checkstyle/nohttp-checkstyle-suppressions.xml - name: SonarQube analysis if: env.PROJECT_KIND == 'maven' run: | ./mvnw -B org.sonarsource.scanner.maven:sonar-maven-plugin:4.0.0.4121:sonar \ -Dsonar.projectKey="${SONAR_PROJECT_KEY}" \ -Dsonar.host.url="${SONAR_HOST_URL}" \ -Dsonar.token="${SCAN_TOKEN}" \ -Dsonar.java.source=17 \ -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml # ── Non-Maven path ──────────────────────────────────────────────────── # # Everything below runs only when there is no pom.xml, and feeds the SAME quality gate: # the scan token, the gate poll, the Backstage notification and the token revoke are all # language-agnostic and are not duplicated. - name: Set up Node if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true' uses: actions/setup-node@v4 with: node-version: '20' # Tests must not fail the job. A quality gate reports on what it measured; a repository # with no test script, or one whose suite needs a database this job does not have, still # deserves to be analysed. Coverage simply goes unreported, and the gate says so. - name: Install and test if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true' continue-on-error: true env: # Prisma validates DATABASE_URL when the client is constructed, and construction # happens at import time -- including in suites that mock every query and never # open a connection. A placeholder is enough to let those run; nothing here # connects to it. Without it the RealWorld service tests fail on # `Environment variable not found: DATABASE_URL` (7 of 27), which is not a finding # about the code, and coverage goes unreported for a suite that would have passed. DATABASE_URL: postgresql://test:test@localhost:5432/test run: | set -uo pipefail if [ -f package-lock.json ]; then npm ci; else npm install; fi npm test --if-present -- --coverage 2>/dev/null || npm test --if-present || true if [ -f coverage/lcov.info ]; then echo "SONAR_LCOV=coverage/lcov.info" >> $GITHUB_ENV echo "Found coverage/lcov.info — it will be reported." else echo "::notice::No coverage/lcov.info produced; analysing without coverage." fi - name: SonarQube analysis (scanner CLI) if: env.PROJECT_KIND == 'cli' run: | set -euo pipefail # Pinned. `latest` would move the analyser under a quality gate that is supposed to # mean the same thing from one run to the next. SCANNER_VERSION=6.2.1.4610 SCANNER_DIR="sonar-scanner-${SCANNER_VERSION}-linux-x64" # This job runs in whatever image the application's build needs, so unzip is not a # given -- the same reason the nohttp step above uses awk rather than python3. command -v unzip >/dev/null 2>&1 || { (apt-get update -qq && apt-get install -y -qq unzip) >/dev/null 2>&1 \ || { echo "::error::unzip is unavailable and could not be installed"; exit 1; } } curl -sSLo /tmp/sonar-scanner.zip \ "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SCANNER_VERSION}-linux-x64.zip" unzip -q /tmp/sonar-scanner.zip -d /opt export PATH="/opt/${SCANNER_DIR}/bin:${PATH}" # The scanner is a Java program and ships no JRE. Most build images have one; if # this repository's does not, say so plainly rather than surface a bare # "java: not found" from inside the wrapper script. command -v java >/dev/null 2>&1 || { echo "::error::sonar-scanner needs a JRE and none is on PATH in this image." exit 1 } # Excluded because they are this platform's artifacts, not the application's code, # and counting them would move the gate for reasons the developer cannot act on. # Mirrors the nohttp allowlist on the Maven path. ARGS=( -Dsonar.projectKey="${SONAR_PROJECT_KEY}" -Dsonar.host.url="${SONAR_HOST_URL}" -Dsonar.token="${SCAN_TOKEN}" -Dsonar.sources=. -Dsonar.exclusions="**/node_modules/**,**/coverage/**,**/dist/**,**/build/**,k6/**,overlays/**,.platform/**,.gitea/**" ) if [ -n "${SONAR_LCOV:-}" ]; then ARGS+=(-Dsonar.javascript.lcov.reportPaths="${SONAR_LCOV}") fi sonar-scanner "${ARGS[@]}" - 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 "══════════════════════════════════════════" 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 "--- Backstage notification debug ---" echo "BACKSTAGE_URL: ${BACKSTAGE_URL:-}" echo "BACKSTAGE_CLEANUP_TOKEN set: $([[ -n "$BACKSTAGE_CLEANUP_TOKEN" ]] && echo yes || echo no)" echo "SONAR_PROJECT_KEY: ${SONAR_PROJECT_KEY:-}" echo "GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}" if [[ -z "$BACKSTAGE_URL" ]]; then echo "::error::BACKSTAGE_URL is not set — cannot send notification" exit 0 fi if [[ -z "$BACKSTAGE_CLEANUP_TOKEN" ]]; then echo "::error::BACKSTAGE_CLEANUP_TOKEN is not set — cannot send notification" exit 0 fi # Built with jq rather than a quoted heredoc-by-hand. The previous version # interpolated shell variables into a \"-escaped string and got one line wrong -- # `"description": "Quality gate failed for ...` was unescaped, so bash closed the # string early and tried to run `gate` as a command. Nobody saw it because this # step only runs when the quality gate fails. jq also escapes the branch name and # project key, which are attacker-adjacent (a branch may contain a quote). PAYLOAD=$(jq -nc \ --arg project "${SONAR_PROJECT_KEY}" \ --arg branch "${GITHUB_HEAD_REF}" \ --arg link "${SONAR_HOST_URL}/dashboard?id=${SONAR_PROJECT_KEY}" \ '{ recipients: { type: "entity", entityRef: "group:default/platform-engineering" }, payload: { title: "SonarQube Quality Gate Failed", description: "Quality gate failed for \($project) on branch \($branch).", link: $link, severity: "high", topic: "sonarqube-quality-gate" } }') echo "Sending notification to: ${BACKSTAGE_URL}/api/notifications" 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_CLEANUP_TOKEN}" \ -d "${PAYLOAD}") echo "HTTP response code: ${HTTP_CODE}" echo "Response body:" cat /tmp/bs-notify-response.json 2>/dev/null || echo "" 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: | # Revoke THIS run's token, never the bare `gitea-scan-` name -- that # name belongs to no single job, and revoking it here is what killed a concurrent # job's live credential. TOKEN_NAME is empty if bootstrap never ran, in which case # there is nothing to revoke and a blank name would be a no-op request at best. if [[ -z "${TOKEN_NAME:-}" ]]; then echo "No scan token was generated — nothing to revoke." exit 0 fi curl -sf -X POST \ -u "${SONAR_ADMIN_TOKEN}:" \ "${SONAR_HOST_URL}/api/user_tokens/revoke" \ --data-urlencode "name=${TOKEN_NAME}" \ && echo "✅ Scan token revoked (${TOKEN_NAME})" \ || echo "⚠️ Token revocation failed"