Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97e2fddfcd | |||
| 62c8ee68b4 |
103
.gitea/workflows/sonarqube.yml
Normal file
103
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
# Run SonarQube static analysis against the code that just landed on `main` and
|
||||
# report the results to the self-hosted SonarQube server for review. This is
|
||||
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
|
||||
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
|
||||
# (which gates PRs) and release.yml (which publishes the bundle) — this one only
|
||||
# feeds the dashboard.
|
||||
#
|
||||
# Mirrors RunicGateway/website's sonarqube.yml, for the same reason pr-checks.yml
|
||||
# does: this module is two npm packages shaped like that repo's `server/` and
|
||||
# `client/`, and it is loaded into that repo's process. Until now it was the one
|
||||
# part of the platform that had never been scanned — 75 files that arrived in the
|
||||
# Phase 3 extraction with core's Sonar history left behind in core's project.
|
||||
#
|
||||
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
|
||||
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
|
||||
# My Account → Security in SonarQube for the
|
||||
# Module-uo project (or a global one).
|
||||
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
|
||||
# http://192.168.0.56:9000
|
||||
# (kept as a variable, not committed, so the internal address stays out of git.)
|
||||
#
|
||||
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
|
||||
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
|
||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||
# dashboard when you want to.
|
||||
|
||||
name: SonarQube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Allow re-running the analysis on demand from the Actions tab.
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sonarqube-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out (full history for accurate new-code + blame)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# SonarQube uses git history to attribute issues to authors and to
|
||||
# compute "new code". A shallow clone degrades both.
|
||||
fetch-depth: 0
|
||||
|
||||
# Node 22, where pr-checks.yml pins 20: the built-in `lcov` coverage
|
||||
# reporter this job depends on needs >= 22. The version that matters for
|
||||
# correctness is the one in pr-checks.yml, which matches the core process
|
||||
# this module is loaded into; nothing here ships.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install deps for both halves
|
||||
run: |
|
||||
npm ci --prefix server
|
||||
npm ci --prefix client
|
||||
|
||||
# The chunk has to exist before the client suite runs: build.test.js and
|
||||
# registration.test.js read `client/dist/entry.js`, and both SKIP when
|
||||
# there is no build. Run the other way round they skip silently and this
|
||||
# job reports coverage for a suite that quietly asked less than it looks
|
||||
# like it did — the same ordering pr-checks.yml calls load-bearing.
|
||||
- name: Build the client chunk
|
||||
run: npm run build --prefix client
|
||||
|
||||
# SonarQube runs static analysis only — it never executes the test suite,
|
||||
# so we must produce the coverage report ourselves and hand it to the
|
||||
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
|
||||
#
|
||||
# Both suites are invoked from the REPO ROOT rather than with `--prefix`,
|
||||
# so the LCOV `SF:` paths come out repo-root-relative (`server/router/...`,
|
||||
# `client/src/...`) and resolve against sonar.sources. That is also why the
|
||||
# server suite's `--require` is spelled out here instead of reusing
|
||||
# `npm test --prefix server`, whose path is relative to `server/`.
|
||||
- name: Generate server test coverage (LCOV)
|
||||
run: |
|
||||
mkdir -p server/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--require ./server/test/_setup.js \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
|
||||
server/test/*.test.js
|
||||
|
||||
- name: Generate client test coverage (LCOV)
|
||||
run: |
|
||||
mkdir -p client/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
|
||||
client/test/*.test.js
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||
@@ -96,6 +96,19 @@ when someone builds on the server is not shippable.
|
||||
branch and no cutover, unlike `website`, whose module work accumulates on `edge`
|
||||
and reaches `main` once.
|
||||
|
||||
### Static analysis runs after the merge, not on the PR
|
||||
|
||||
`.gitea/workflows/sonarqube.yml` scans `main` on push and reports to the
|
||||
self-hosted SonarQube instance under the project key **`Module-uo`**. It is
|
||||
deliberately non-blocking: it never gates a pull request, and a failing quality
|
||||
gate does not fail the job. Check the dashboard when you want to; the things
|
||||
that must not reach `main` are gated by `pr-checks.yml` instead.
|
||||
|
||||
It runs both suites from the repo root to produce coverage, and builds the
|
||||
client chunk first — two of the client tests read `dist/entry.js` and skip
|
||||
without it, which would leave this job reporting on a suite that quietly asked
|
||||
less than it appears to.
|
||||
|
||||
### Commit messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||
|
||||
64
scripts/sonar-test-reporter.mjs
Normal file
64
scripts/sonar-test-reporter.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
|
||||
//
|
||||
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
|
||||
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
|
||||
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
|
||||
// lcov report only populates Coverage, which is why the dashboard shows coverage
|
||||
// while the Unit Tests tile stays "-". This reporter produces that missing report.
|
||||
//
|
||||
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
|
||||
// <testExecutions version="1">
|
||||
// <file path="server/test/foo.test.js">
|
||||
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
|
||||
// </file>
|
||||
// </testExecutions>
|
||||
//
|
||||
// Paths are emitted repo-root-relative (POSIX separators) so they match the
|
||||
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
|
||||
// absolute `file` on each event strips cleanly against process.cwd().
|
||||
import path from 'node:path'
|
||||
|
||||
function xmlEscape(s) {
|
||||
return String(s).replace(/[<>&"']/g, (c) => ({
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[c])
|
||||
}
|
||||
|
||||
export default async function* sonarTestReporter(source) {
|
||||
const byFile = new Map()
|
||||
const cwd = process.cwd()
|
||||
|
||||
for await (const event of source) {
|
||||
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
|
||||
const d = event.data
|
||||
// Skip the container events (a `describe` suite) and anything without a file
|
||||
// — only real test cases go in the report, so the count matches the runner's.
|
||||
if (!d.file || (d.details && d.details.type === 'suite')) continue
|
||||
|
||||
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
|
||||
if (!byFile.has(rel)) byFile.set(rel, [])
|
||||
byFile.get(rel).push({
|
||||
name: d.name,
|
||||
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
|
||||
failed: event.type === 'test:fail',
|
||||
skipped: Boolean(d.skip || d.todo),
|
||||
})
|
||||
}
|
||||
|
||||
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
|
||||
for (const [file, cases] of byFile) {
|
||||
yield ` <file path="${xmlEscape(file)}">\n`
|
||||
for (const c of cases) {
|
||||
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
|
||||
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
|
||||
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
|
||||
else yield ` <testCase ${attrs}/>\n`
|
||||
}
|
||||
yield ' </file>\n'
|
||||
}
|
||||
yield '</testExecutions>\n'
|
||||
}
|
||||
54
sonar-project.properties
Normal file
54
sonar-project.properties
Normal file
@@ -0,0 +1,54 @@
|
||||
# SonarQube analysis config for module-uo.
|
||||
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
|
||||
# The project key must match the one created in SonarQube (dashboard URL
|
||||
# ?id=Module-uo).
|
||||
|
||||
sonar.projectKey=Module-uo
|
||||
sonar.projectName=Module-uo
|
||||
|
||||
# Analysed application code.
|
||||
#
|
||||
# Unlike core's repo there is no `src/` directory to point at: the server half
|
||||
# keeps its code at `server/` root (boot.js, core.js, index.js) beside its
|
||||
# subdirectories, so the whole tree is included and the non-source parts are
|
||||
# excluded below. That direction is deliberate — a new top-level server
|
||||
# directory is scanned by default rather than silently unscanned, which is the
|
||||
# safer way for this list to be wrong.
|
||||
#
|
||||
# `client/scripts` and `server/scripts` are in, not out: checkExternals.js and
|
||||
# checkImports.js *are* the enforcement of MODULE_API.md §3.6 and §5.1, they
|
||||
# each carry their own test suite, and both have already shipped defects that a
|
||||
# reviewer missed (see MODULE_SYSTEM.md §2.7.1). Build code that decides whether
|
||||
# a release is allowed out is not throwaway code.
|
||||
sonar.sources=server,client/src,client/scripts
|
||||
|
||||
# Test code is analysed separately from sources so coverage/metrics attribute
|
||||
# correctly. Both halves run on Node's built-in test runner (no browser/DOM):
|
||||
# the server suite is CommonJS behind test/_setup.js, the client's is ESM.
|
||||
sonar.tests=server/test,client/test
|
||||
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
|
||||
|
||||
# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
|
||||
# test-coverage and writes an LCOV report for each BEFORE the scan runs; without
|
||||
# them the dashboard shows 0% (the scanner never executes tests itself). Both
|
||||
# suites are invoked from the repo root so the `SF:` paths come out
|
||||
# repo-root-relative (server/..., client/src/...) and the scanner resolves them
|
||||
# against the project base dir.
|
||||
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
|
||||
|
||||
# Test execution ("Unit Tests" measure). A SEPARATE report from coverage: the
|
||||
# lcov files above only populate Coverage, so without this the dashboard shows a
|
||||
# coverage % but an empty "Unit Tests" tile. Written by scripts/sonar-test-reporter.mjs,
|
||||
# a copy of core's — a pure leaf build helper, which is the side of the vendoring
|
||||
# line that may be copied (MODULE_SYSTEM.md §2.7.1).
|
||||
sonar.testExecutionReportPaths=server/coverage/test-execution.xml,client/coverage/test-execution.xml
|
||||
|
||||
# Never analyse dependencies, build output, generated artifacts, or fixtures.
|
||||
#
|
||||
# `client/dist` is the built chunk (gitignored, but the workflow builds it before
|
||||
# scanning because client/test/{build,registration}.test.js import it).
|
||||
# `server/swagger/doc.js` and the two committed generated artifacts at the repo
|
||||
# root are inputs to and outputs of swagger-autogen, not hand-written code.
|
||||
sonar.exclusions=**/node_modules/**,server/test/**,client/test/**,client/dist/**,server/swagger/**,server/data/**,server/coverage/**,client/coverage/**,**/*.min.js
|
||||
|
||||
sonar.sourceEncoding=UTF-8
|
||||
Reference in New Issue
Block a user