Monorepos, Multi-Module Builds, Generated Code, and Complex Repository Layouts: Guided Hands-On Workflow
Build a disposable two-module repository, compare one-project and multi-project analysis topologies, prove indexed/report ownership, preserve a deliberate report-path failure, and repair it at the same revision.
Learning objectives
- Create a disposable two-module Git repository with tests, coverage reports, generated/vendor directories, and exact revision evidence.
- Analyze the repository once as one SonarQube project and then as two independently owned Community Build projects.
- Prove indexed-file and report ownership using scanner logs, report contents, task IDs, measures, and fixed Git SHA.
- Engineer a wrong module-relative coverage path that preserves source indexing but drops coverage evidence.
- Repair only the report-path coordinate at the same revision and prove the resulting measure change independently.
- Keep every project token, scanner work directory, report artifact, and cleanup action bounded to the lab.
1. Disposable workflow contract
| State | Lab value |
|---|---|
| Server |
Community Build 26.9.0.129388 at
http://localhost:9000
|
| Scanner | SonarScanner CLI 8.1.0.6389 |
| Repository | sq-ch24-layout-lab, one Git history |
| Whole project | sq-ch24-all |
| Module projects | sq-ch24-api, sq-ch24-worker |
| Producer |
Python 3.11+ with pytest and
pytest-cov
|
| Tokens | Three disposable project-analysis tokens in environment variables |
2. Build the two-module fixture
mkdir -p sq-ch24-layout-lab/modules/api/{src,tests}
mkdir -p sq-ch24-layout-lab/modules/worker/{src,tests}
mkdir -p sq-ch24-layout-lab/{generated,vendor,evidence}
cd sq-ch24-layout-lab
git init
git config user.name "SonarQube Learner"
git config user.email "learner@example.invalid"
cat > modules/api/src/pricing.py <<'PY'
def discounted(total: float, vip: bool) -> float:
return total * 0.9 if vip else total
PY
cat > modules/api/tests/test_pricing.py <<'PY'
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from pricing import discounted
def test_vip_discount():
assert discounted(100.0, True) == 90.0
PY
cat > modules/worker/src/jobs.py <<'PY'
def next_delay(attempt: int) -> int:
if attempt <= 0:
return 1
return min(60, 2 ** attempt)
PY
cat > modules/worker/tests/test_jobs.py <<'PY'
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from jobs import next_delay
def test_first_retry():
assert next_delay(1) == 2
PY
cat > generated/client_generated.py <<'PY'
# Synthetic generated output; not first-party ownership in this lab.
SCHEMA_VERSION = "demo-v1"
PY
cat > vendor/third_party_copy.py <<'PY'
# Synthetic stand-in for vendored code; not an actual third-party package.
def external_helper():
return "demo"
PY
cat > .gitignore <<'EOF'
.venv/
__pycache__/
.pytest_cache/
.scannerwork*/
evidence/
*.coverage
EOF
git add .
git commit -m "baseline two-module layout"
git rev-parse HEAD | tee /tmp/ch24-revision.txt
The generated/vendor directories are deliberately present so the ownership decision is visible rather than hidden. They are not automatically included in the module project scopes.
3. Produce module reports before scanning
python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pytest pytest-cov
python -m pytest --version | tee evidence/pytest-version.txt
python -m coverage --version | tee evidence/coverage-version.txt
(
cd modules/api
../../.venv/bin/python -m pytest tests --cov=src --cov-report=xml:coverage.xml
)
(
cd modules/worker
../../.venv/bin/python -m pytest tests --cov=src --cov-report=xml:coverage.xml
)
# Inspect report paths before importing them.
grep -n 'filename=' modules/api/coverage.xml | head | tee evidence/api-report-paths.txt
grep -n 'filename=' modules/worker/coverage.xml | head | tee evidence/worker-report-paths.txt
Each report was produced from its module directory, so its internal
paths should map naturally to that module when the scanner uses the
same module as projectBaseDir.
4. Preflight three project identities without embedding secrets
Create the three disposable projects in the local UI. Generate a project-analysis token for each. Keep the values only in the shell or a local secret manager.
export SONAR_HOST_URL="http://localhost:9000"
read -rsp "sq-ch24-all token: " SONAR_TOKEN_ALL; echo
read -rsp "sq-ch24-api token: " SONAR_TOKEN_API; echo
read -rsp "sq-ch24-worker token: " SONAR_TOKEN_WORKER; echo
export SONAR_TOKEN_ALL SONAR_TOKEN_API SONAR_TOKEN_WORKER
git rev-parse HEAD | tee evidence/revision.txt
sonar-scanner --version | tee evidence/scanner-version.txt
curl -fsS "$SONAR_HOST_URL/api/system/status" | tee evidence/server-status.json
5. Design A: one SonarQube project for the whole repository
For the comparison scan, generate one coverage report from the repository root so report-internal paths use the same coordinate system as the whole-project scan.
.venv/bin/python -m pytest modules/api/tests modules/worker/tests \
--cov=modules/api/src --cov=modules/worker/src \
--cov-report=xml:coverage-all.xml
grep -n 'filename=' coverage-all.xml | head | tee evidence/all-report-paths.txt
SONAR_TOKEN="$SONAR_TOKEN_ALL" sonar-scanner -X \
-Dsonar.projectKey=sq-ch24-all \
-Dsonar.projectName="SQ Ch24 Whole Repository" \
-Dsonar.sources=modules/api/src,modules/worker/src \
-Dsonar.tests=modules/api/tests,modules/worker/tests \
-Dsonar.python.coverage.reportPaths=coverage-all.xml \
-Dsonar.working.directory=.scannerwork-all \
2>&1 | tee evidence/all-scanner.log
cp .scannerwork-all/report-task.txt evidence/all-report-task.txt
This design produces one history, one New Code definition, one permission boundary and one Quality Gate for both modules. That can be correct when the modules share ownership and release lifecycle. It is wrong merely because “the repository is one folder.”
6. Design B: two SonarQube projects from the same Git revision
SONAR_TOKEN="$SONAR_TOKEN_API" sonar-scanner -X \
-Dsonar.projectKey=sq-ch24-api \
-Dsonar.projectName="SQ Ch24 API" \
-Dsonar.projectBaseDir=modules/api \
-Dsonar.sources=src \
-Dsonar.tests=tests \
-Dsonar.python.coverage.reportPaths=coverage.xml \
-Dsonar.working.directory=.scannerwork-ch24-api \
2>&1 | tee evidence/api-scanner.log
SONAR_TOKEN="$SONAR_TOKEN_WORKER" sonar-scanner -X \
-Dsonar.projectKey=sq-ch24-worker \
-Dsonar.projectName="SQ Ch24 Worker" \
-Dsonar.projectBaseDir=modules/worker \
-Dsonar.sources=src \
-Dsonar.tests=tests \
-Dsonar.python.coverage.reportPaths=coverage.xml \
-Dsonar.working.directory=.scannerwork-ch24-worker \
2>&1 | tee evidence/worker-scanner-good-baseline.log
cp modules/api/.scannerwork-ch24-api/report-task.txt evidence/api-report-task.txt
cp modules/worker/.scannerwork-ch24-worker/report-task.txt evidence/worker-report-task-good-baseline.txt
Both scans use the same Git SHA but distinct project keys, bases, report files, scanner work directories and tokens. Inspect logs to prove the API project did not index worker source and vice versa.
grep -Ei 'files indexed|indexing|coverage|report' evidence/api-scanner.log | tee evidence/api-index-report-summary.txt
grep -Ei 'files indexed|indexing|coverage|report' evidence/worker-scanner-good-baseline.log | tee evidence/worker-index-report-summary.txt
7. Deliberate defect: duplicate the module prefix in the report path
Now keep the worker source, Git SHA, project key, profile and gate unchanged. Change only the coverage report parameter:
# BROKEN ON PURPOSE: projectBaseDir is already modules/worker.
SONAR_TOKEN="$SONAR_TOKEN_WORKER" sonar-scanner -X \
-Dsonar.projectKey=sq-ch24-worker \
-Dsonar.projectBaseDir=modules/worker \
-Dsonar.sources=src \
-Dsonar.tests=tests \
-Dsonar.python.coverage.reportPaths=modules/worker/coverage.xml \
-Dsonar.working.directory=.scannerwork-ch24-worker-broken \
2>&1 | tee evidence/worker-scanner-broken.log
cp modules/worker/.scannerwork-ch24-worker-broken/report-task.txt \
evidence/worker-report-task-broken.txt
grep -Ei 'coverage|report.*not|no report|cannot' evidence/worker-scanner-broken.log \
| tee evidence/worker-broken-report-evidence.txt || true
The expected causal signature is: source files still index, scanner/report upload may still complete, but the coverage importer cannot find the intended report. Preserve the first broken log and task evidence. Do not “fix” the dashboard by excluding worker files from coverage.
8. Repair only the coordinate and prove the same-revision result
git rev-parse HEAD | tee evidence/revision-before-repair.txt
SONAR_TOKEN="$SONAR_TOKEN_WORKER" sonar-scanner -X \
-Dsonar.projectKey=sq-ch24-worker \
-Dsonar.projectBaseDir=modules/worker \
-Dsonar.sources=src \
-Dsonar.tests=tests \
-Dsonar.python.coverage.reportPaths=coverage.xml \
-Dsonar.working.directory=.scannerwork-ch24-worker-fixed \
2>&1 | tee evidence/worker-scanner-fixed.log
cp modules/worker/.scannerwork-ch24-worker-fixed/report-task.txt \
evidence/worker-report-task-fixed.txt
git rev-parse HEAD | tee evidence/revision-after-repair.txt
cmp evidence/revision-before-repair.txt evidence/revision-after-repair.txt
grep -Ei 'coverage|report' evidence/worker-scanner-fixed.log \
| tee evidence/worker-fixed-report-evidence.txt
Then query the project measure after Compute Engine completes. The important proof is not a particular percentage—it is that the corrected scan imports the report under the same source revision while the broken scan did not.
9. Preserve asynchronous task evidence for every scan
TASK_ID="$(sed -n 's/^ceTaskId=//p' evidence/worker-report-task-fixed.txt)"
echo "$TASK_ID" | tee evidence/worker-fixed-ceTaskId.txt
curl -fsS -H "Authorization: Bearer $SONAR_TOKEN_WORKER" \
"$SONAR_HOST_URL/api/ce/task?id=$TASK_ID" \
| tee evidence/worker-fixed-ce.json
curl -fsS -H "Authorization: Bearer $SONAR_TOKEN_WORKER" \
"$SONAR_HOST_URL/api/measures/component?component=sq-ch24-worker&metricKeys=ncloc,coverage,line_coverage" \
| tee evidence/worker-fixed-measures.json
Poll until the task is terminal before interpreting measures. Scanner process success, report upload, Compute Engine success, project analysis completion and Quality Gate result remain separate states.
10. Challenge: which layer owns the defect?
The worker has ncloc but no imported coverage after a
successful scanner upload. The report file exists at
modules/worker/coverage.xml. Which layer should you
inspect first?
- Record
sonar.projectBaseDir. - Resolve the configured report path from that base.
- Inspect the report’s internal filenames.
- Inspect scanner import logs.
- Only then inspect server measures/CE state.
Changing the Quality Gate, coverage exclusions, project key, or source files would alter unrelated state and hide the path defect.
Knowledge check
Why generate module coverage reports before the scanner?
SonarQube imports reports produced by the test tool; it does not execute tests or create the coverage evidence.
What stays constant during the deliberate worker report-path failure and repair?
The Git SHA, project key, source/test scope, quality profile and gate. Only the report-path coordinate changes.
Why use separate project-analysis tokens for API and worker?
It limits a leaked scanner credential to its own project and makes project ownership explicit.
If the broken scan uploads successfully, did coverage import necessarily succeed?
No. Scanner/report upload and individual sensor/report imports are different evidence states; inspect the scanner log and resulting measures.
Why compare one-project and two-project designs on the same revision?
It isolates topology/ownership differences from source-code differences.
Official references and version notes
-
Community Build — analysis parameters not settable in UI
—
sonar.projectBaseDir,sonar.sources,sonar.tests,sonar.working.directory, report-task path and token guidance. - Community Build — setting initial scope — source/test roots are simple paths relative to project base directory; no wildcards in initial roots.
- Community Build — analysis-scope introduction — generated/library code, coverage/duplication exclusions and verification workflow.
- Community Build — test coverage parameters — externally generated reports and project-root-relative path rules.
- Community Build — SonarScanner CLI — alternate project base directory behavior and CLI configuration.
- Community Build — Java/multi-module coverage — build-native JaCoCo generation and multi-module aggregation patterns.
- SonarQube Server — managing monorepo projects — Enterprise monorepo feature, multiple SonarQube projects bound to one repository, unique project keys and manual project definition.
- SonarQube Server — Applications — synthetic aggregation for projects sharing a lifecycle; commercial feature.
- SonarQube Server — Portfolios — Enterprise governance aggregation.
- SonarScanner CLI releases — current public scanner release baseline.
- SonarQube downloads — current Community Build/Server/LTA release identities.
Rechecked 2026-09-08. Mandatory examples target
Community Build 26.9.0.129388 and
SonarScanner CLI 8.1.0.6389. For CLI-managed
projects, sonar.projectBaseDir changes the analysis
directory; sonar.sources/sonar.tests are
simple paths relative to that base unless absolute. Most
coverage/external report paths are project-root/base-relative
unless their parameter docs state otherwise.
sonar.working.directory must be unique for each
project and is deleted before each analysis, so parallel jobs must
isolate scanner/report artifacts. Current native monorepo
onboarding/binding is an
Enterprise Edition feature and still requires
explicit projects; SonarQube does not infer monorepo projects
automatically. Applications are a commercial aggregation starting
in Developer Edition; Portfolios start in Enterprise Edition.
Community Build can still model multiple independent projects from
one repository manually with distinct project keys/base
directories and an external governance-summary fallback. Recheck
the exact scanner/build integration and report parameter semantics
before production use.
projectBaseDir,
source/test roots, inclusions/exclusions, generated/vendor policy,
report producer/path/internal filenames, unique scanner working
directory, token owner/type (never value), scanner log,
report-task.txt, ceTaskId, Compute Engine
state, measures/gate, CI concurrency identity and aggregation
edition/definition separately.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.