sonarqube-setup
Configures SonarQube analysis in a generated or adopted Spring Boot project — the scanner plugin in the root build file, the project key and server URL, a CI step when the server is external, and a lo
SKILL.md.claude/skills/sonarqube-setup/SKILL.md
GitHubSonarQube Setup
Wires a project to a SonarQube server so the code quality the norms describe is measured by an analyzer, not only reviewed. One question decides the shape: does a server already exist? Yes → the build and CI point at it. No → a local container runs it, and the build points there.
Entry rule: configure once. If the root build file already declares the scanner —
sonar-maven-plugin in pom.xml, org.sonarqube in build.gradle — report "already
configured", name the file and line, and stop. Re-running this skill never rewrites a
working setup; changing the server is a hand edit of two properties.
The token is a secret and is never written. Not in pom.xml, not in build.gradle,
not in the workflow, not "temporarily". The scanner reads SONAR_TOKEN from the
environment on its own; the build file carries the URL, which is not a secret, and
nothing else about authentication.
Why this is a skill (Form 1) and not Form 2
Form 1: project-bootstrap and arch-adopt chain it by name, and a skill with
disable-model-invocation: true cannot be called through the Skill tool — the same
reason git-publish and docker-architect stay model-invocable. Axis 2 of the
interview — two callers, one of which runs inside a project where project-bootstrap
does not exist — is what made it a skill of its own instead of a step inside the
bootstrap. The closest rejected form was that step: arch-adopt would then have to
write pom.xml and docker-compose.yml itself, widening its territory and giving the
compose file a second owner. Record: @.claude/decisions/0091-sonarqube-setup-skill.md.
Runs on sonnet with effort: low: a fixed procedure behind one question, every
version resolved by curl (@.claude/decisions/0080-procedural-skills-on-sonnet.md).
Procedure
Project directory. When the caller's one-line context names project: <absolute path>,
that directory is the project root, not the session's: read and write every path of this
procedure under it, and run every shell command as (cd "<path>" && …). When this skill chains
docker-architect, pass project: <path> on in its context.
1 · Detect the build tool and whether Sonar is already there
pom.xml at the root → maven. build.gradle at the root → gradle. Neither → stop:
there is no project to configure. Both → stop and say so; this repo never generates both.
Grep the root build file for sonar-maven-plugin / org.sonarqube. Found → entry rule
above: report and stop.
2 · Ask about the server — one AskUserQuestion
Two questions in the same call:
- Server — "Is there already a SonarQube server or SonarCloud organization for this
project?"
- Existing SonarQube server — the user types its URL under "Other" or in a follow-up.
- SonarCloud — the URL is
https://sonarcloud.io; ask the organization key. - None — run one locally — a
sonarqubecontainer is added todocker-compose.yml(step 4).
- Authentication — "How does the analysis authenticate?"
- Token in
SONAR_TOKEN(default) — the variable name only; the value lives in the developer's shell and in the CI secret of the same name. - Anonymous — the server allows analysis without a token. Rare outside a local
container; say so. On a local SonarQube it is not the default: three server
settings have to change first, and the report lists them (§ Report). Without them the
scan fails only at its end, after a full
verify, one missing setting per run.
- Token in
When a caller already passed an answer in its one-line context, don't ask it again.
3 · Write the build configuration
Resolve the scanner version on the repository itself — never from memory, same rule
project-bootstrap/references/build-maven.md § 4.6 applies to every plugin:
# maven
curl -sS 'https://repo1.maven.org/maven2/org/sonarsource/scanner/maven/sonar-maven-plugin/maven-metadata.xml' | grep -o '<release>[^<]*</release>'
# gradle — the plugin lives on the Gradle Plugin Portal, not on Maven Central
curl -sS 'https://plugins.gradle.org/m2/org/sonarqube/org.sonarqube.gradle.plugin/maven-metadata.xml' | grep -o '<release>[^<]*</release>'
No network → don't write a version: say which one could not be resolved and let the user supply it.
Then merge — never replace what the root build file already has:
| Tool | Exemplar | Where |
|---|---|---|
| maven | templates/pom-sonar.xml.example |
properties into the root POM's <properties>, the plugin into <build><pluginManagement><plugins> |
| gradle | templates/build-gradle-sonar.example |
the plugin into the root plugins {}, the sonar {} block at the root level — not inside subprojects |
sonar.projectKey is <groupId>:<artifactId> from the root build file. sonar.host.url
is the URL from step 2, or http://localhost:9000 for the local container.
sonar.organization only for SonarCloud. The coverage report path needs no property: the
scanner's default report paths are exactly where the JaCoCo setup project-bootstrap writes
puts them (target/site/jacoco/jacoco.xml, build/reports/jacoco/test/jacocoTestReport.xml).
The coverage scope does need one, sonar.coverage.exclusions, and it is derived, never
typed. @.claude/rules/testing.md § Coverage leaves classes out of the calculation, and the
root build file already says which: the <excludes> of jacoco-maven-plugin's
<configuration>, or the exclude: list of jacocoTestReport's classDirectories. Sonar
does not read that list. A class missing from jacoco.xml shows up there as uncovered, so
without the property Sonar measures a scope the build gate never measured. Read those
patterns from the project's own build file, not from the template, because the project may
have changed them. Turn each trailing .class into .java and keep directory patterns as
they are (**/*Config.class → **/*Config.java, **/config/** unchanged). Write them
comma-separated, in their order. This is coverage exclusion only, never sonar.exclusions:
the classes still get their issues analyzed. When the build file has no JaCoCo excludes,
write no property and say so in the report.
The sonar.issue.ignore.multicriteria entries go in as written, whether or not the files
they name exist yet: each is scoped to one rule and one file, a file absent from the project
matches nothing, and the templates that produce those files — idempotency, and
security-architect's SecurityConfig — point back at them.
Don't add an entry of your own here — a finding in the project's code is fixed, not muted.
4 · Local server — only when step 2 answered None
Invoke docker-architect through the Skill tool with a one-line context:
"service sonarqube, chained from sonarqube-setup — no engine question". It owns every
service block in docker-compose.yml, and its template for this one is
templates/sonarqube-service.yml.example there. Don't write the block here, not even
when it looks like a copy: that is the ownership split, not a style choice.
No docker-compose.yml at the root → docker-architect stops on its own entry rule.
Report that the build points at http://localhost:9000 and nothing runs there yet.
5 · CI — only when the server is external
A local container is unreachable from a CI runner, so this step runs only for an
existing server or SonarCloud. Merge templates/ci-sonar-step.yml.example into
.github/workflows/build.yml (the file project-bootstrap step 6.5 writes): job-level
env, fetch-depth: 0 on the checkout, and the analysis step after verify. The step
is skipped while the SONAR_TOKEN secret is absent — a fork's pull request has none, and
a red build there would say nothing about the code.
No .github/workflows/build.yml → skip, and say it in the report.
Report
✅ SonarQube configured — <existing server <url> | SonarCloud <org> | local container>
Build ....... <pom.xml | build.gradle> — scanner <version> (resolved from <repository>)
Project key . <groupId:artifactId>
Coverage .... sonar.coverage.exclusions = <patterns> (from the JaCoCo excludes in <file>:<line>) | not set — no JaCoCo excludes in <file>
Server ...... <url> · auth: <SONAR_TOKEN | anonymous>
Compose ..... <sonarqube added by docker-architect, tag <tag> | not touched — external server>
CI .......... <step added, runs when secret SONAR_TOKEN is set | skipped — local server | skipped — no workflow>
Access check (before any scan):
curl -s <url>/api/authentication/validate → {"valid":true} when anonymous is allowed
(or SONAR_TOKEN is exported — the scanner reads it)
First run: <./mvnw -B verify sonar:sonar | ./gradlew build sonar>
Rerun: <./mvnw -B sonar:sonar | ./gradlew sonar> — same build output, no tests again
The access check moves an authentication failure from the end of the scan to its start:
validate answers in milliseconds, the scan fails only after verify has run every test.
The rerun line is for a change on the server side — a setting, a permission, a quality
profile — where the code and target/ (or build/) are unchanged.
For the local container, add three lines the person needs on day one: start it with
docker compose up -d sonarqube, the first login is admin/admin and forces a
password change, and a token is created under My Account → Security, then exported as
SONAR_TOKEN.
When step 2 answered Anonymous for the local container, replace the token line with the three settings, in this order, and say they are for a local container only — each one opens the server to anyone who can reach it:
- Administration → Configuration → General Settings → Security — Force user
authentication off. On by default; with it on,
validateanswers{"valid":false}. - Administration → Security → Global Permissions, group Anyone — Execute Analysis. Without it the scanner fails with "You're not authorized to analyze this project or the project doesn't exist on SonarQube and you're not authorized to create it."
- Same page, same group — Create Projects. Needed for the first scan only, while the
project key does not exist yet. For an external server, the one thing to do by hand: create the
SONAR_TOKENrepository secret.
Contract
Class: build — the territory is skill_classes.build's override for this skill in
@.claude/schemas/extensions.json: the root build file and .github/workflows/**,
nothing else. ArchHook.java guard enforces it. docker-compose.yml is deliberately
outside it.
Reads the root pom.xml or build.gradle (coordinates, existing plugins, the JaCoCo
excludes that sonar.coverage.exclusions is derived from), and
.github/workflows/build.yml when it exists.
Writes the scanner plugin and the sonar.* properties into the root build file —
the only change to that file outside a feature round besides project-bootstrap's
generation and the two installers' own setup — and the analysis step into the workflow
when the server is external.
Never writes a token, a password, or any credential value, in any file.
Delegates the sonarqube compose service to docker-architect, the single owner of
every service block.
Chained by project-bootstrap (step 8.4, after Verify) and arch-adopt (step 7), and invocable
by hand. Travels into the generated project (export.skills.include): arch-adopt
runs inside the project, where project-bootstrap does not exist, and needs this skill
there.