diff --git a/.github/workflows/reusable-ci-astro.yml b/.github/workflows/reusable-ci-astro.yml index 0281df2..9648751 100644 --- a/.github/workflows/reusable-ci-astro.yml +++ b/.github/workflows/reusable-ci-astro.yml @@ -206,7 +206,7 @@ jobs: # Static analysis only — this Astro reusable has no test/coverage job, so no # coverage artifact is handed over. !cancelled() so it still runs alongside # build/lint regardless of their result. - if: inputs.enable-sonar && !cancelled() + if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled() needs: build permissions: contents: read diff --git a/.github/workflows/reusable-ci-go.yml b/.github/workflows/reusable-ci-go.yml index 2b68528..4b8fe81 100644 --- a/.github/workflows/reusable-ci-go.yml +++ b/.github/workflows/reusable-ci-go.yml @@ -268,7 +268,7 @@ jobs: name: SonarQube # !cancelled() so analysis still runs when tests fail (coverage may be partial) # or when the test job is skipped (enable-test / enable-coverage off). - if: inputs.enable-sonar && !cancelled() + if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled() # needs: test so the coverage artifact exists before the scan downloads it. needs: test permissions: diff --git a/.github/workflows/reusable-ci-node.yml b/.github/workflows/reusable-ci-node.yml index c311c0f..2b3c77f 100644 --- a/.github/workflows/reusable-ci-node.yml +++ b/.github/workflows/reusable-ci-node.yml @@ -285,7 +285,7 @@ jobs: sonarqube: name: SonarQube # !cancelled() so analysis still runs when tests fail (coverage may be partial). - if: inputs.enable-sonar && !cancelled() + if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled() # needs: test so the coverage artifact exists before the scan downloads it. needs: test permissions: diff --git a/.github/workflows/reusable-ci-rust.yml b/.github/workflows/reusable-ci-rust.yml index d6df599..e636e3d 100644 --- a/.github/workflows/reusable-ci-rust.yml +++ b/.github/workflows/reusable-ci-rust.yml @@ -622,7 +622,7 @@ jobs: # !cancelled() so analysis runs even when the coverage job is skipped # (enable-coverage false / release commit) or fails — keep enable-coverage on # for full reports. - if: inputs.enable-sonar && !cancelled() + if: inputs.enable-sonar && github.event_name != 'pull_request' && !cancelled() needs: coverage permissions: contents: read diff --git a/.github/workflows/reusable-sonarqube-scan.yml b/.github/workflows/reusable-sonarqube-scan.yml index 762a09f..3c65e12 100644 --- a/.github/workflows/reusable-sonarqube-scan.yml +++ b/.github/workflows/reusable-sonarqube-scan.yml @@ -62,33 +62,15 @@ concurrency: permissions: contents: read - pull-requests: write jobs: sonarqube: name: SonarQube analysis + if: github.event_name != 'pull_request' runs-on: ${{ inputs.runner != '' && inputs.runner || (github.event.repository.private && 'ferrlabs-k8s' || 'ubuntu-latest') }} - # Pull requests all analyse into the same `-pr` sandbox project, so two - # concurrent PRs on one repo would overwrite each other's analysis. Serialise - # them per repo. Pushes to the default branch are unaffected (own group). - concurrency: - group: sonar-${{ inputs.project-key || github.event.repository.name }}-${{ github.event_name == 'pull_request' && 'pr' || github.ref }} - cancel-in-progress: false env: SONAR_HOST: ${{ vars.SONAR_HOST_URL || 'https://sonar.ferrlabs.com' }} - # SonarQube Community has no branch or pull-request analysis — the - # `api/project_pull_requests/*` endpoints return 404, and a scan carries no - # branch identity. So a scan launched from a PR used to land on the - # project's ONLY analysis and overwrite the default branch's picture with - # the PR's code: the dashboard showed whichever run finished last, and - # every "new code" figure was meaningless. - # - # A pull request therefore analyses into its own sandbox project. The - # default branch keeps a clean, stable analysis, and the two projects are - # comparable, which is what lets the diff step below report what this pull - # request actually introduces. - BASE_PROJECT: ${{ inputs.project-key || github.event.repository.name }} - SONAR_PROJECT: ${{ github.event_name == 'pull_request' && format('{0}-pr', inputs.project-key || github.event.repository.name) || (inputs.project-key || github.event.repository.name) }} + SONAR_PROJECT: ${{ inputs.project-key || github.event.repository.name }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -139,168 +121,3 @@ jobs: -Dsonar.exclusions=${{ inputs.exclusions }} ${{ steps.sast.outputs.scanner-args }} ${{ inputs.args }} - - # Everything below only runs on a pull request. It turns the two analyses - # (this PR's sandbox, and the default branch) into the one thing a reviewer - # wants: what did THIS change introduce. - - - name: Wait for SonarQube to finish processing - if: github.event_name == 'pull_request' - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - working-directory: ${{ inputs.working-directory }} - # The scanner returns as soon as the report is uploaded; SonarQube - # ingests it asynchronously. Querying the API before the compute-engine - # task finishes returns the PREVIOUS analysis, so the diff would silently - # describe the wrong commit. report-task.txt carries the task id. - run: | - set -euo pipefail - task=$(grep '^ceTaskId=' .scannerwork/report-task.txt | cut -d= -f2) - for _ in $(seq 1 60); do - status=$(curl -sS -u "${SONAR_TOKEN}:" "${SONAR_HOST}/api/ce/task?id=${task}" \ - | python3 -c 'import json,sys; print(json.load(sys.stdin)["task"]["status"])') - case "$status" in - SUCCESS) echo "analyse ingérée"; exit 0 ;; - FAILED|CANCELED) echo "::error::SonarQube task $status"; exit 1 ;; - esac - sleep 5 - done - echo "::error::SonarQube n'a pas fini d'ingérer l'analyse en 5 minutes" - exit 1 - - - name: Diff against the default branch - id: diff - if: github.event_name == 'pull_request' - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - python3 <<'PY' - import json, os, urllib.request, urllib.parse, base64, pathlib - - host = os.environ["SONAR_HOST"].rstrip("/") - token = os.environ["SONAR_TOKEN"] - gh = os.environ["GH_TOKEN"] - repo = os.environ["REPO"] - pr = os.environ["PR_NUMBER"] - - def get(url, headers): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=60) as r: - return json.load(r) - - sonar_auth = {"Authorization": "Basic " + base64.b64encode(f"{token}:".encode()).decode()} - gh_auth = {"Authorization": f"Bearer {gh}", "Accept": "application/vnd.github+json"} - - def issues(project): - """Every open issue of a project, keyed by what survives a rebase.""" - out, page = {}, 1 - while True: - q = urllib.parse.urlencode({ - "componentKeys": project, "resolved": "false", - "ps": 500, "p": page, - }) - d = get(f"{host}/api/issues/search?{q}", sonar_auth) - for i in d.get("issues", []): - # Deliberately NOT keyed on line number: a pull request that - # only shifts lines would otherwise report every pre-existing - # issue in the file as newly introduced. - path = i.get("component", "").split(":", 1)[-1] - out[(i.get("rule"), path, i.get("message"))] = i - if page * 500 >= d.get("paging", {}).get("total", 0): - return out - page += 1 - - changed = set() - page = 1 - while True: - d = get(f"https://api.github.com/repos/{repo}/pulls/{pr}/files?per_page=100&page={page}", gh_auth) - if not d: - break - changed.update(f["filename"] for f in d) - if len(d) < 100: - break - page += 1 - - pr_issues = issues(os.environ["SONAR_PROJECT"]) - base_issues = issues(os.environ["BASE_PROJECT"]) - - introduced = [ - v for k, v in pr_issues.items() - if k not in base_issues and k[1] in changed - ] - fixed = [ - v for k, v in base_issues.items() - if k not in pr_issues and k[1] in changed - ] - - rank = {"BLOCKER": 0, "HIGH": 1, "CRITICAL": 1, "MEDIUM": 2, "MAJOR": 2, "LOW": 3, "MINOR": 3, "INFO": 4} - introduced.sort(key=lambda i: rank.get(i.get("severity", "INFO"), 9)) - - pathlib.Path("sonar-delta.json").write_text(json.dumps({ - "introduced": introduced, "fixed": len(fixed), - "host": host, "project": os.environ["SONAR_PROJECT"], - })) - print(f"{len(introduced)} introduite(s), {len(fixed)} corrigée(s)") - PY - - - name: Comment on the pull request - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - python3 <<'PY' - import json, os, urllib.request - - d = json.load(open("sonar-delta.json")) - gh, repo, pr = os.environ["GH_TOKEN"], os.environ["REPO"], os.environ["PR_NUMBER"] - # One comment per pull request, found by this marker and edited in - # place. A scan runs on every push; without this the thread fills with - # near-identical comments and nobody reads any of them. - MARKER = "" - - intro = d["introduced"] - if intro: - lines = [f"### SonarQube — {len(intro)} issue(s) introduite(s) par cette PR", ""] - for i in intro[:25]: - path = i.get("component", "").split(":", 1)[-1] - line = i.get("line") or (i.get("textRange") or {}).get("startLine") - where = f"`{path}`" + (f" L{line}" if line else "") - lines.append(f"- **{i.get('severity','?')}** {where} — {i.get('message')} `{i.get('rule')}`") - if len(intro) > 25: - lines.append(f"\n_… et {len(intro) - 25} autres._") - else: - lines = ["### SonarQube — aucune nouvelle issue", ""] - if d["fixed"]: - lines.append(f"\n{d['fixed']} issue(s) corrigée(s) sur les fichiers touchés.") - lines.append( - "\nComparaison entre le projet bac à sable de cette PR et la branche par défaut : " - "SonarQube Community n'analyse pas les PR, ce delta est calculé côté CI. " - f"[Détail]({d['host']}/dashboard?id={d['project']})" - ) - body = MARKER + "\n" + "\n".join(lines) - - def api(method, url, payload=None): - req = urllib.request.Request( - url, method=method, - data=json.dumps(payload).encode() if payload else None, - headers={"Authorization": f"Bearer {gh}", - "Accept": "application/vnd.github+json", - "Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=30) as r: - return json.load(r) - - existing = next( - (c for c in api("GET", f"https://api.github.com/repos/{repo}/issues/{pr}/comments?per_page=100") - if MARKER in c.get("body", "")), None) - if existing: - api("PATCH", f"https://api.github.com/repos/{repo}/issues/comments/{existing['id']}", {"body": body}) - else: - api("POST", f"https://api.github.com/repos/{repo}/issues/{pr}/comments", {"body": body}) - PY