Skip to content

SonarQube Integration: Feasibility Assessment

This page assesses whether the installed SonarQube for IDE (SonarLint) VS Code extension can be wired into the Claude Code / NEFOXX development workflow automatically, without running a server, and — since the answer to that turned out to be more nuanced — lays out the actual zero-cost, industry-standard CI/CD pipeline (static analysis, quality gates, and dev/production deployment) that should be built on top of GitHub, given this is a solo-developer, private, cost-sensitive project.

Status (2026-07-07): implemented. What follows started as a feasibility assessment and design spec; the CI/CD pipeline it describes (ESLint hardening, GitHub Actions build/test/Sonar gate, and the dev/UAT/production deploy layer) is now live — dev and UAT have both been verified end-to-end, production is wired and ready for its first manual trigger. The Manual developer setup steps checklist below is kept as the authoritative record of what's done (✅) vs. the few remaining items that are pure GitHub Settings toggles (Dependabot, code scanning, branch protection) — see dev tracker NFX-025 for the live status of those.

1. Current state

  • CLAUDE.md already mandates static code analysis ("Run static code analysis (SonarQube or equivalent) wherever available and act on its findings; never ship code with unresolved high/critical issues") as a non-negotiable engineering principle — but there is currently zero concrete tooling behind that mandate.
  • No .sonarlint/ config, no sonar-project.properties, no SonarQube/SonarCloud connection anywhere in the repo.
  • No .github/workflows/ — there is no CI pipeline at all today, not just no Sonar job. Nothing automatically gates lint, tests, or builds on push or PR.
  • .claude/settings.json / settings.local.json have no hooks key — nothing runs automatically after an edit today. npx eslint * and npm run lint/test are only in the Bash permission allowlist for manual/on-demand use.
  • eslint.config.mjs runs react, react-hooks, and import plugins only — no security or Sonar-equivalent rule plugins (eslint-plugin-security, eslint-plugin-sonarjs) are installed.
  • Quality/security findings today are captured only via the manual /code-review and /security-review Claude Code skills, plus manual logging into the dev tracker (e.g. an existing entry catching a "code smell" via deno check).
  • Deployment is currently manual: npm run build locally, then the contents of dist/ are uploaded by hand to whichever Cloudflare Pages destination is intended.

2. Standalone (extension-only) vs. server-connected capability

Available standalone (no server): Bugs and Code Smells from the bundled open-source "Sonar Way" rule set (JS/TS/Python/etc. analyzers embedded in the extension), on-the-fly as-you-type analysis in the editor, and quick fixes for a subset of rules.

Server-gated (SonarQube/SonarCloud "Connected Mode" required): Security Hotspot review/triage workflow, taint analysis (dataflow/injection-style vulnerability detection — commercial editions only, and requires a server binding), custom/organization quality profiles (standalone uses only the fixed default profile), Quality Gates, duplication (CPD) detection across a project, historical trend/dashboard, PR decoration, and the full paid rule set beyond the open-source subset.

Automation/headless verdict: SonarLint's engine (sonarlint-core) ships as an IDE-embedded language server (LSP over stdio) designed to feed the editor's UI — it has no supported CLI or exposed local API for third-party/non-interactive invocation. The sonar-scanner CLI (the tool that can run headlessly/in CI) is an upload client — it always talks to a SonarQube/SonarCloud server; there is no supported "offline scanner" mode that produces a report with zero server.

Conclusion: triggering SonarLint's analysis from a Claude Code hook, without a human opening files in the IDE and without any server, is not achievable through supported tooling. It remains a human-in-the-editor tool only.

3. Gap analysis

GapCovered today?By whatClosable without a server?
Bugs / code smellsPartiallyStandalone SonarLint (manual, in-editor only)Yes — see §4
Security Hotspots, taint analysisNoNothingNo — requires Connected Mode
Quality GatesNoNothingYes, locally (§4); yes in CI (§4b)
Duplication detection, history/trendsNoNothingOnly with a persistent server (§4d discusses the tradeoff)
PR decorationNoNothingNo — SonarCloud-only feature; not adopted here (§4b)
Automated triggering on editNoNothingPartially — ESLint layer only (§4), not SonarLint itself

The bigger existing gap is that there's no CI pipeline at all — even non-Sonar automation (lint, tests, coverage) isn't gated today. This assessment addresses that gap directly in §4b–§4c, since a Sonar-only fix would leave the larger problem unsolved.

4. Recommendation

Extension-only automation is not achievable in the sense of "a hook triggers SonarLint headlessly." It remains a human-in-the-editor tool only.

Minimal-friction path that fits the zero-infrastructure constraint: add eslint-plugin-sonarjs (reimplements a large share of Sonar's JS/TS bug and code-smell rules as ESLint rules) and eslint-plugin-security as devDependencies to eslint.config.mjs. This runs through the already-allowlisted npm run lint / npx eslint * command, which genuinely can be wired into a Claude Code PostToolUse hook — closing most of the "automatic, no-server" gap using tooling already in the permission allowlist.

If true SonarQube-grade coverage (Quality Gates, taint analysis, duplication, trends, PR decoration) is wanted, a server is unavoidable — but it does not need to be always-on, and it does not need new infrastructure. Since Docker Desktop is already part of the local dev environment (used for the Supabase stack), a localhost SonarQube Community Edition container is a low-friction addition on the same Docker Desktop instance, scoped to active development sessions:

  • Start it deliberately at the start of a work session (docker run -d --name sonarqube -p 9000:9000 sonarqube:community), point sonar-scanner/the SonarLint extension's Connected Mode at http://localhost:9000, then stop/remove it when the session ends (docker stop sonarqube). No system service, no persistent background process, no cost when not actively developing.
  • This unlocks the fuller Connected Mode ruleset and Quality Gates locally — but it's still an in-editor/on-demand experience, not a Claude Code hook trigger. A hook can shell out to sonar-scanner against this localhost server as a scripted step, which is automatable (unlike bare SonarLint) — the one path that combines "no SonarLint headless limitation" with "still local/on-demand, no paid cloud infra."
  • Tradeoffs: SonarQube CE is JVM/Elasticsearch-backed — budget ~2GB+ RAM and a 30–60s first-boot time. Community Edition excludes taint analysis (needs Developer Edition or SonarCloud), and history/trends only persist if a data volume is mounted and kept between sessions.

4a. Direct answer: localhost (16GB RAM) vs. AWS free-tier EC2

Localhost is workable and the right choice — do not move this to AWS. SonarQube CE needs ~2GB RAM minimum, ~4GB comfortable; a 16GB machine running it alongside the existing dev stack (Node, Docker Desktop, editor, browser) has ample headroom.

AWS free-tier EC2 is a poor fit for hosting SonarQube itself, for three concrete reasons:

  1. RAM — the free-tier instance types (t2.micro/t3.micro) have only 1GB RAM, below SonarQube's stated minimum; it would need swap-based workarounds that hurt reliability, for a workload the local 16GB machine already handles natively.
  2. Not actually zero-cost — AWS Free Tier is 12 months from account creation, after which the same instance is billed. It's a trial, not a permanent free tier, so it fails the "literally zero cost" requirement on any timeline beyond a year.
  3. New maintenance surface — a self-managed EC2 instance means patching the OS, securing SSH/inbound rules, and backing up the container's data volume — overhead with no capability gain over localhost.

Verdict: keep SonarQube on localhost via Docker Desktop, on-demand per session. If a shared/CI-reachable server is wanted later, that's a different need — solved by the CI-side design in §4b/§4c, not by self-hosting on EC2.

4b. Zero-cost, industry-standard CI automation via GitHub

The repo has no CI at all today. For a solo developer wanting an industry-standard process at zero added cost, GitHub's own free tier covers most of this — but measuring the actual codebase changes the initial SonarCloud answer.

Measured codebase size (via wc -l, excluding node_modules/dist):

Scanned areaLines
src/ (JS/JSX — frontend, incl. co-located tests)62,112
supabase/functions/ (TS — Edge Functions)35,463
supabase/migrations/ (SQL)52,861
JS/JSX + TS alone (what Sonar's JS/TS analyzers actually meter)~97,575
documentations/ (Markdown)88,050 — not source code, excluded from any Sonar scan

SonarCloud's Free plan (50k private LOC cap, unlimited public LOC) does not fit. The JS/JSX+TS surface alone is already ~2x the 50k free cap, before counting future growth — this isn't a borderline case, it's a clear miss. Making the repo public to unlock SonarCloud's unconditional free tier is not an option: this is a proprietary startup codebase (nefoxx.com, business logic, Edge-Function-adjacent code) — the source stays private, full stop.

The path that fits: self-host the scan engine inside the GitHub Actions job itself, instead of using SonarCloud SaaS. GitHub Actions supports service containers — a sonarqube:community container started as part of the workflow run, live only for that job's duration, torn down automatically when the job ends. sonar-scanner in the same job points at http://localhost:9000 (the service container). This has no LOC cap at all (you're running the open-source engine yourself, inside an ephemeral CI runner instead of your own machine), stays entirely inside GitHub Actions' free minutes, and the repo never has to leave private.

  • Tradeoff vs. SonarCloud: no native PR-decoration UI (SonarCloud posts inline PR comments automatically; this approach needs the job to post its own summary, or you accept the GitHub Actions run summary as the record) — acceptable for a solo developer prioritizing zero cost and staying closed-source over SaaS dashboard polish.

Yes, GitHub Actions can do this, and yes it's inside the free planservices: (job-level service containers) is a native, documented feature; a service container runs on the same job runner, for the same job's duration, consuming no minutes beyond the job's own runtime.

Concrete configuration challenges to plan for:

  1. Elasticsearch bootstrap check (vm.max_map_count) — SonarQube CE embeds Elasticsearch, which refuses to start unless the host kernel's vm.max_map_count >= 262144. This is the single most common self-hosted-SonarQube failure. Add an explicit sudo sysctl -w vm.max_map_count=262144 step before the service container starts — do not assume the runner's default is sufficient.
  2. Startup/readiness gating — SonarQube takes tens of seconds to become scan-ready after the container starts; a TCP-port-open check is not enough. Configure the service's health-cmd against /api/system/status (looking for "status":"UP") with sufficient retries/interval.
  3. Runner resources — GitHub-hosted Linux runners (Free plan, private repos) are commonly ~2 vCPU / 7GB RAM. SonarQube CE wants ~2–4GB; running it alongside test:coverage/build in the same job adds memory pressure. Put the Sonar scan in its own job, separate from lint/test/build.
  4. No analysis history / "new code" baseline — because the container is destroyed after each job, there's no prior analysis to diff against. This is not a minor caveat; see the explicit decision in §4d and the Quality Gate persistence choice in the checklist below.
  5. Image pull time & scan duration on ~97.5k LOC — mitigate with actions/cache for node_modules and by triggering the Sonar job only on pull_request/pushes to main/develop, not every commit.
  6. Default-credential anti-pattern — do not authenticate sonar-scanner with a fixed/default admin token, even on an isolated ephemeral runner. Generate a fresh token via SonarQube's REST API right after the health check passes, use it for that run only, let it die with the container.
  7. Supply-chain hardening — pin every third-party Action (sonarsource/..., cloudflare/wrangler-action, actions/cache, etc.) to a full commit SHA, not a version tag — mutable tags are a known supply-chain attack vector.
NeedZero-cost GitHub-native answerNotes
Run lint/test/build on every push & PRGitHub ActionsFree: unlimited minutes on public repos; 2,000 min/month free on private repos (Free plan) — comfortably enough for solo use.
Server-side static analysis + Quality GateSelf-hosted SonarQube CE as a GitHub Actions service containerNo LOC cap, private repo stays private, runs entirely inside free Actions minutes; loses SonarCloud's hosted dashboard/PR-decoration polish.
Dependency vulnerability alertsDependabot alerts + security updatesFree and native on GitHub for both public and private repos.
Static code scanning (SAST) as a second layerCodeQL / GitHub code scanningUnconditionally free on public repos; on private repos, depends on the account's GitHub Advanced Security entitlement — verify before relying on it as a required check.
Block merges until checks passBranch protection rules + required status checks + required PRsFree, native GitHub feature.

Documentation should never be scanned. Sonar's rule engines target source code; Markdown carries no such rules. sonar-project.properties should set sonar.sources=src,supabase/functions (excluding documentations/, node_modules, dist) — matching how ESLint and Vitest already scope themselves to src/.

4c. Deploy targeting — dev vs. production, generalized for future environments

Today's deploy is manual: build locally, upload dist/ by hand to whichever Cloudflare Pages destination. Since a Cloudflare↔GitHub Git integration isn't set up, the GitHub Actions pipeline needs its own explicit deploy step, using npx wrangler pages deploy dist --project-name=<project> (the already-installed wrangler devDependency directly — not the cloudflare/wrangler-action originally proposed here, avoiding another third-party Action to pin/trust for something already available).

✅ Implemented: a reusable, parameterized deploy workflow, not hardcoded per-environment jobs..github/workflows/deploy.yml is one shared workflow_call target (inputs: environment_name, cf_project, domain); each environment is a separate, small caller:

EnvironmentRefCF Pages project (repo variable)DomainTrigger
devdevelopCF_PAGES_PROJECT_DEVdevv.nefoxx.comAutomatic — deploy-dev job in ci.yml, needs: [build-and-test, sonar-scan]
uatpicked at dispatch time (typically develop)CF_PAGES_PROJECT_UATuatt.nefoxx.comManual — deploy-uat.yml, workflow_dispatch
productionpicked at dispatch time (typically main)CF_PAGES_PROJECT_PRODnefoxx.comManual — deploy-prod.yml, workflow_dispatch

UAT was added after the initial dev/prod rollout, confirming the design goal: one new table row, one new small workflow file mirroring deploy-prod.yml's verify-ci + call pattern — zero changes to deploy.yml itself, no duplicated wrangler logic, no environment-specific code anywhere in the frontend. The intended promotion flow is dev (auto, continuous) → UAT (manual, on demand, once dev looks good) → production (manual, on demand, once UAT succeeds) — each stage a deliberate step up from the last.

  • Revised approval mechanism (discovered 2026-07-06): the original plan called for a GitHub Environment production with a required reviewer — verified against GitHub's own docs that this protection rule is not available for private repos below the Enterprise plan (public repos get it on any plan). Upgrading just for this wasn't proportionate, and for a solo developer a manual trigger is arguably the more honest equivalent anyway. Both deploy-uat.yml and deploy-prod.yml are workflow_dispatch-only (no automatic push trigger) with a verify-ci job that queries the GitHub API for the exact commit's check-run conclusions and refuses to proceed unless CI truly passed — this replaces the structural needs: link that isn't possible once deploy lives in a separate workflow file from build-and-test/sonar-scan.
  • Operational gotcha: GitHub's "Run workflow" ref picker defaults to the repo's default branch (main) regardless of which workflow you're running — when triggering deploy-uat.yml, you must manually change the dropdown to develop (or whichever commit you're promoting); it will not do this automatically.
  • Post-deploy verification: each deploy job ends with a retrying HTTP smoke-test against the deployed domain (Cloudflare Pages deployments take a few seconds to propagate globally, so a single immediate check isn't reliable) before the job is considered successful. Rollback is Cloudflare Pages' existing one-click "roll back to previous deployment" in its dashboard (already true today) — documented here as the incident-response step, since it exists but wasn't written down anywhere.
  • Branch protection: require pull requests (no direct pushes) on develop and main, in addition to required status checks — otherwise CI can be bypassed entirely. See Git Setup & Workflow for the day-to-day mechanics this assumes. (Still a pending user action — not yet configured as of this writing.)
  • Secrets/variables (already configured): CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID as GitHub Actions secrets; CF_PAGES_PROJECT_DEV/CF_PAGES_PROJECT_UAT/CF_PAGES_PROJECT_PROD as GitHub Actions variables (deliberately not secrets — project names aren't sensitive, and GitHub Actions has vars specifically for non-secret config).
  • Hidden assumption to flag explicitly: this pipeline only controls which frontend build goes where — dev, UAT, and production currently share one Supabase backend project. Testing on devv.nefoxx.com/uatt.nefoxx.com writes to the same database as nefoxx.com. True environment data isolation is a separate, larger architectural decision (additional Supabase projects/schemas), out of scope here but must not be assumed away.
  • Existing docs note: guides/deployment.md §4 still describes "Shipping to production (Hostinger)" — stale since the 2026-07-02 move to Cloudflare Pages. Worth a follow-up correction, separate from this assessment.

Cloudflare Pages prerequisite — Production branch must match environment_name

Discovered via a real first deploy, 2026-07-07 — this is a mandatory one-time setup step per Cloudflare Pages project, easy to miss because it fails silently.

wrangler pages deploy --branch=<value> does not refer to a git branch. Each of these three Cloudflare Pages projects is a direct-upload project with no Git repository connected (confirmed in its own Settings → Build page: "Git repository: Connect" — still unconnected, by design, since GitHub pushes the build via wrangler instead). With no Git integration, --branch is purely Cloudflare's own internal label: each project has one designated Production branch string (Settings → General → Production branch, a free-text field with a Rename action — not a dropdown of real branches), and any wrangler upload whose --branch value matches that string is published as a Production deployment (updates the project's custom domain). Anything else is filed as an unlisted Preview deployment on a random *.pages.dev URL and never touches the custom domain — with wrangler and the GitHub Actions job both reporting success, and no error surfaced anywhere.

This is exactly what happened on the first real deploy-dev run: the dev-nefoxx project's Production branch had been left at its dashboard default of main, while deploy.yml originally passed --branch as the triggering git ref (github.ref_name, i.e. develop for a dev deploy). Every dev deploy landed as an invisible Preview build; devv.nefoxx.com kept serving a three-day-old deployment from main the entire time, with a fully green CI run and a passing smoke test (against the stale production content) giving no indication anything was wrong.

Fix implemented (commit 8d2275c): deploy.yml now passes a fixed, per-environment label instead of the git ref:

yaml
run: npx wrangler pages deploy dist --project-name="${{ inputs.cf_project }}" --branch="${{ inputs.environment_name }}" --commit-hash="${{ github.sha }}"

This also closes a latent, higher-stakes version of the same trap: deploy-prod.yml is workflow_dispatch-only, and GitHub's "Run workflow" ref picker does not default to main — if a developer ever ran it against the wrong ref, github.ref_name would have silently produced a Preview deploy to nefoxx.com with zero error. Keying off the fixed environment_name input instead removes that dependency entirely.

Mandatory one-time prerequisite — do this before the first real deploy to each project: in the Cloudflare dashboard, for each of the three Pages projects, go to Settings → General → Production branch → Rename, and set it to match that project's fixed label:

Cloudflare Pages projectServesProduction branch must be renamed to
dev project (e.g. dev-nefoxx)devv.nefoxx.comdev
UAT projectuatt.nefoxx.comuat
production projectnefoxx.comproduction

Common point of confusion (screenshotted during actual setup): the "Choose Environment: Production / Preview" dropdown near the top of a project's Settings page is a different control — it only toggles which environment's variables/secrets you're viewing on that page. It does not rename or configure the Production branch; that's the separate field further down, under General.

Related discovery from the same incident — Cloudflare Bot Fight Mode blocked the CI smoke test. Once the branch-label fix above was in place, the post-deploy HTTP smoke test in deploy.yml still failed with a persistent 403 on every retry, even though the site loaded correctly in a real browser. Root cause: Cloudflare's Bot Fight Mode was scoring the smoke test's plain curl request as bot traffic — curl's default User-Agent string is a well-known bot signature, and GitHub Actions runners originate from cloud/datacenter IP ranges (Azure) that Cloudflare's reputation heuristics treat as higher-risk than typical residential/browser traffic. Disabling Bot Fight Mode on the affected zone resolved it immediately. This is a tradeoff, not a final answer — it weakens bot protection for real traffic too. If Bot Fight Mode needs to be re-enabled later, the smoke test should be revisited first, in order of preference: (1) send a realistic browser User-Agent header from curl (fixes a pure User-Agent-string block), (2) add a Cloudflare WAF "Skip" rule keyed on a custom header + secret value sent only by CI (allow-lists just the smoke test without weakening protection generally), or (3) replace the public HTTP check with a Cloudflare API/wrangler deployment-status check instead of hitting the live edge at all (sidesteps the WAF, at the cost of not proving true end-to-end reachability).

4d. When should SonarQube analysis actually run? (cadence guidance)

Why is the local Docker SonarQube container framed as session-based/optional rather than mandatory for every change? Because the tools involved have very different costs, and matching cost to frequency is the actual best practice — not "scan constantly" or "scan only at the end." This is a four-tier cadence:

TierToolTrigger / frequencyWhy this cadence
1 — continuousStandalone SonarLint (already built into the installed extension, zero setup, zero Docker)Every keystroke/save, automaticallyInstant, in-process, effectively free — there's no reason to gate this behind a session; it should simply always be on. This does not require the Docker container at all.
2 — focused-sessionLocal Docker SonarQube, Connected ModeStarted once when beginning focused work on a feature/PR; re-analyzes continuously in the editor for the rest of that session; stopped at session end"Optional per session," not "optional per change" — once started, it covers every change made during that session automatically via Connected Mode's live analysis, just with the fuller ruleset. It's session-scoped because starting a ~2–4GB JVM+Elasticsearch container has real cost (RAM, ~30–60s boot) that makes "start it fresh for every edit" impractical — you start it once, it stays live and reactive for the whole session, like leaving a dev server running.
2a — ad hoc within a sessionSame running container, manual sonar-scanner invocationWhenever a fresh full-project pass is wanted mid-session (e.g. right before opening a PR)If Connected Mode's live per-file analysis isn't enough, run sonar-scanner against the already-running localhost:9000 container — no restart needed.
3 — pre-commit (recommended, lightweight)npm run lint (eslint + sonarjs + security plugins) via a Claude Code PostToolUse hookAutomatically, on every file editThe tier that legitimately should run on every change — fast (no JVM, no container), catches the same class of bug/code-smell issues at editor-hook speed.
4 — before merge (mandatory, non-negotiable)GitHub Actions sonar-scan jobAutomatically, only on git push/PR (see the diagram below)The actual enterprise gate — the only tier that cannot be skipped, is independent of any individual developer's machine/session, and blocks merge on failure. Tiers 1–2a are fast local feedback that reduce how often tier 4 fails; tier 4 is what actually enforces the standard.

Direct recommendation: don't try to make the heavy local SonarQube container run "for every code change" — that's what tier 1 (already-installed standalone SonarLint) and tier 3 (hook-triggered ESLint) are for, and they already cover "every change" at negligible cost. Reserve the Docker container (tier 2) for a per-feature/per-PR working session, and treat CI (tier 4) as the only tier that is truly mandatory and unskippable. Cheap checks run constantly; expensive checks run at natural checkpoints (session start, pre-PR, and always at the actual gate).

5. Flow diagram — the single, authoritative end-to-end solution

Every stage of the proposed solution appears here in the order it actually happens: local editing → local on-demand Sonar → explicit git push → CI quality gate → dev deploy → manual promotion → production deploy. Nothing here exists today — this is 100% proposed future state (§1, Current State, is what exists today, by contrast).

Does CI fire on local file edits/saves? No. The diagram is drawn in two clearly separated zones precisely to make this unambiguous:

  • Local machine zone (① and ②): every file save, every npm run lint run, every local SonarQube session — none of this ever touches GitHub. It can happen any number of times, entirely offline, and nothing in the CI/deploy zone is triggered by any of it.
  • GitHub zone (③ and ④): the only thing that crosses from local to GitHub is an explicit git commit + git push (or opening a PR) — a deliberate developer action, not a save-file event. CI has no visibility into the local editor at all; it only reacts to what actually lands in the remote repository.

Reading the diagram, stage by stage:

  1. — every file edit already can trigger npm run lint via a Claude Code hook; the fastest, cheapest feedback loop, runs entirely on the local machine, needs no server and no GitHub involvement at all.
  2. — during active development, a developer can optionally start a localhost SonarQube container (Docker Desktop) for the fuller Connected Mode ruleset in the editor; stopped at session end — never an always-on cost, still 100% local.
  3. The boundary — nothing so far has touched GitHub. The only event that crosses from local to remote is a deliberate git push (or opening a PR) — never an automatic consequence of saving a file or running a local lint/scan.
  4. — only once that push (or PR) actually reaches GitHub does GitHub Actions run lint/test/build, then a second job runs the same Sonar engine as a disposable service container (no LOC cap, no SaaS, stays private) — the first point nothing can slip through unnoticed, entirely GitHub-side.
  5. — only a passing Quality Gate can produce a deploy at all; pushing to develop auto-ships to devv.nefoxx.com for hands-on testing. Promoting further is always a deliberate, manual workflow_dispatch click — never automatic — and each promotion step (deploy-uat.yml, deploy-prod.yml) independently re-verifies via the GitHub API that CI actually passed for the exact commit being deployed before proceeding, since those workflows no longer share a structural needs: link with build-and-test/sonar-scan once they live in their own files. Each wrangler call also uses a fixed --branch label (dev / uat / production) rather than the triggering git ref — see the Cloudflare Pages prerequisite above for why that distinction matters and the one-time dashboard setup it requires per project.

Note on generalization: ④ is implemented as one reusable deploy workflow parameterized by environment (deploy.yml), called from three small, environment-specific workflows — adding a future staging/QA environment means one more small calling workflow plus one more Cloudflare Pages project prerequisite (a new row in the table above), not redesigning the pipeline. Similarly, ③'s Quality Gate carries the persistence-model decision from the checklist below (baseline-artifact vs. self-hosted-runner) — that decision must be made explicitly before implementation, not defaulted.

6. Manual developer setup steps (for when this is actually implemented)

A concrete, numbered checklist for a future implementer — none of this is executed by this assessment.

Editor + local layer:

  1. npm install -D eslint-plugin-sonarjs eslint-plugin-security and register both in eslint.config.mjs.
  2. Add a PostToolUse hook entry in .claude/settings.json running npm run lint after file edits.
  3. Pull the image once: docker pull sonarqube:community.
  4. Session start: docker run -d --name sonarqube -p 9000:9000 sonarqube:community (or a compose service), wait for http://localhost:9000 to report healthy (first boot ~30–60s).
  5. In the SonarQube for IDE / SonarLint extension: configure Connected Mode (requires the container from step 4 to be running and reachable — this whole step needs a live server, it can't be completed offline). Verified against Sonar's own docs, 2026-07-05:
    1. Activity Bar → SONARQUBE SETUP > CONNECTED MODE view container.
    2. Add SonarQube Server Connection.
    3. Server URL: http://localhost:9000 · Connection Name: anything memorable (e.g. nefoxx-local).
    4. User Token: generate one at http://localhost:9000/account/security/ — must be a User Token specifically; project/global/organization tokens don't work with Connected Mode.
    5. Save Connection, confirm it shows connected in the CONNECTED MODE view.
    6. Add Project Binding for that connection → pick this repo's local folder → pick the matching remote project (project key nefoxx, per sonar-project.properties). If no project exists yet on this fresh container, run sonar-scanner against it once first (auto-creates the project) — see step 2a below.
    7. Optional but recommended: click the export/arrow icon to write the binding to a .sonarlint folder file and commit it, so anyone else opening this repo with the extension installed gets prompted to bind automatically instead of repeating steps 1–6. Once bound, every file you open shows the fuller connected rule profile (not just SonarLint's default standalone "Sonar way" set) — including the real per-file findings behind NFX-021's 12,792 code smells, live and inline as you touch each feature.
  6. Session end: docker stop sonarqube && docker rm sonarqube (or docker compose down) — no container left running.

GitHub layer (accounts for the measured ~97.5k LOC):

  1. Implemented.github/workflows/ci.yml's build-and-test job (push/pull_request triggers on main/develop) runs, in order: npm run lintnpm run test (hard gate; also emits --reporter=json alongside the normal console reporter) → Test Summary (writes a feature-grouped, failures-first table to the GitHub Actions Job Summary — see below) → npm run test:coverage (non-blocking — see dev tracker NFX-019) → npm run build. Dependency caching uses actions/setup-node's built-in cache: 'npm', not a raw actions/cache on node_modules (a no-op under npm ci, which always rebuilds node_modules from package-lock.json regardless of what's cached there).
  2. Implemented — decision (a) taken. sonar-baseline.json at the repo root holds the real measured baseline (bugs: 80, vulnerabilities: 0, codeSmells: 12792 — see NFX-021); .github/scripts/sonar-baseline-check.cjs fails the sonar-scan job only if a run's counts exceed the committed baseline. Option (b) (self-hosted runner for true "new code" gating) was not taken — the availability tradeoff wasn't worth it for a solo-developer setup; this can be revisited later without changing the client-side design.
  3. Implementedsonar-scan is its own job in ci.yml (needs: build-and-test, own runner), with vm.max_map_count set explicitly, the sonarqube:community container started via a plain docker run + polling /api/system/status for readiness (not a services: block — that would start the container before the vm.max_map_count step runs), and a fresh admin token generated via the REST API after the health check passes (see the password-policy note in the job for the exact openssl/curl sequence).
  4. Implementedsonar-project.properties at the repo root sets sonar.sources=src,supabase/functions, keeping documentations/, node_modules, and dist out of every scan.
  5. Implementedsonar-scan inherits ci.yml's trigger (push/pull_request on main/develop only), not every commit on every branch.
  6. (Pending — user action, GitHub Settings, cannot be done from the repo/CLI) Enable Dependabot:
    1. Repo → Settings → Code security.
    2. Dependabot alertsEnable.
    3. Dependabot security updatesEnable (auto-opens a PR when a vulnerable dependency has a fixed version — requires alerts to be on first).
  7. (Pending — user action, GitHub Settings, cannot be done from the repo/CLI) Evaluate/enable code scanning:
    1. Repo → Settings → Code security → Code scanning.
    2. If CodeQL analysis shows a Set up button (not greyed out / not requiring a paid plan upgrade), click Default setup and let GitHub configure it.
    3. If it's gated behind a GitHub Advanced Security upgrade prompt for this private repo's current plan, skip it for now — don't add it as a required status check until it's actually enabled and running, or deploy-* promotions could end up blocked on a check that never produces a result.
  8. (Pending — user action, GitHub Settings, cannot be done from the repo/CLI) Branch protection — the one remaining gap that lets CI be bypassed by a direct push:
    1. Repo → Settings → Branches → Add branch ruleset (or Add rule on the classic branch protection screen, depending on which UI your account shows).
    2. Target branch: main. Enable Require a pull request before merging (blocks direct pushes, including force-pushes).
    3. Enable Require status checks to pass before merging, and add exactly these two (the same names deploy-prod.yml's verify-ci job already checks by API): Lint, Test, Build and SonarQube Scan (baseline gate).
    4. Repeat steps 1–3 for develop.
    5. Do not enable "Require a pull request" retroactively in a way that blocks the promotion fast-forwards already documented in Git Setup & Workflow — once this is on, advancing main happens via a merged PR from develop instead of git push origin develop:main; update local habits accordingly (see that guide's §8/§9).
  9. Implemented — every third-party Action across ci.yml, deploy.yml, deploy-uat.yml, and deploy-prod.yml is pinned to a full commit SHA (actions/checkout, actions/setup-node), not a version tag. deploy-uat.yml/deploy-prod.yml only reference the local reusable ./.github/workflows/deploy.yml, which isn't a third-party Action and needs no pin.

Deploy layer (non-negotiable, day-1 — dev vs. prod targeting, generalized for future environments):

  1. (User action, manual) Generate a scoped Cloudflare API token and confirm the exact Pages project names for dev/prod:
    1. Cloudflare dashboard → My Profile → API Tokens (or Manage Account → API Tokens for an account-owned token) → Create TokenCustom Token.
    2. Add permission Cloudflare Pages: Edit, scoped to the account.
    3. Create the token and copy it immediately (shown only once).
    4. Note the Account ID, visible in the dashboard sidebar on any domain/account overview page (or npx wrangler whoami locally once logged in via the CLI).
    5. Dashboard → Workers & Pages → note the exact project name/slug for the dev, UAT, and prod sites — these are the literal --project-name values wrangler needs, and may not match the public domain names (devv.nefoxx.com/uatt.nefoxx.com/nefoxx.com) at all.
    6. Mandatory, easy to miss: for each of the three projects, go to Settings → General → Production branch → Rename and set it to dev / uat / production respectively (matching the fixed label each project will receive from deploy.yml — see the Cloudflare Pages prerequisite above for what breaks, silently, if this is skipped).
  2. (User action, manual) Add to GitHub — Settings → Secrets and variables → Actions:
    • Secrets tab (sensitive credentials only): CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.
    • Variables tab (not secrets — deliberately not lumped in with credentials, since project names aren't sensitive and GitHub Actions has vars specifically for non-secret config): CF_PAGES_PROJECT_DEV, CF_PAGES_PROJECT_PROD — the exact project names from step 16.5.
  3. (User action, manual — revised) Settings → Environments → New environment → name it exactly production. Discovered 2026-07-06, verified against GitHub's own docs: "required reviewers" is not available for private repos below the Enterprise plan — it only works unconditionally on public repos. Making this repo public to unlock it isn't an option (same private-source reasoning as the SonarCloud decision in §4b). Revised approach: skip required reviewers entirely; the production Environment still exists (useful for environment-scoped secrets/variables and a clean deployment history in GitHub's UI) but the actual "never deploy without a deliberate decision" guarantee now comes from making the prod deploy manually triggered (see item 20). Optional extra hardening available on this same Environment page, not gated behind Enterprise: set Deployment branches and tags to "Selected branches" → main only, so environment: production can never be used by a run on any other ref.
  4. Implemented.github/workflows/deploy.yml, a reusable workflow_call (inputs: environment_name, cf_project, domain; secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) that checks out, builds (same NODE_OPTIONS heap fix as build-and-test), runs wrangler pages deploy with --project-name, --branch, and --commit-hash flags (exact command shown in the Cloudflare Pages prerequisite section above) using the already-installed wrangler devDependency directly — no cloudflare/wrangler-action needed, avoiding another third-party Action to pin/trust — then a retrying HTTP smoke-test against domain (Cloudflare Pages deployments take a few seconds to propagate globally, so a single immediate check isn't reliable). --branch deliberately uses the fixed environment_name input, not github.ref_name — see the same section; this requires step 16.6's Production-branch rename to already be done on all three Cloudflare projects, or every deploy silently lands as an unlisted Preview build.
  5. Implemented, with two deliberate deviations from the original plan. Dev: deploy-dev lives as an extra job in ci.yml itself (needs: [build-and-test, sonar-scan], guarded by if: github.event_name == 'push' && github.ref == 'refs/heads/develop'), calling deploy.yml via uses: ./.github/workflows/deploy.yml + secrets: inherit — native needs: within the same workflow file, simpler and more direct than the plan's original workflow_run-based idea (avoids its known rough edges: default-branch workflow copy, ref/checkout indirection). Production: given item 18's Enterprise-gating discovery, deploy-prod was moved to its own standalone .github/workflows/deploy-prod.yml, triggered only by workflow_dispatch (a manual "Run workflow" click in the Actions tab) — never automatically on push to main. Since it's no longer in the same workflow as build-and-test/sonar-scan, there's no structural needs: link; a verify-ci job replaces that guarantee by querying the GitHub API (repos/{repo}/commits/{sha}/check-runs) for the exact commit being deployed and refusing to proceed unless both CI jobs' conclusions read success — so "quality gates block deployment" still holds even though the trigger is manual. Adding a future staging/qa environment remains exactly "one more job block calling the same deploy.yml with new inputs," matching the original intent.
  6. Implemented — documented in §4c ("Post-deploy verification": Cloudflare Pages rollback is a one-click "roll back to previous deployment" in its dashboard — the incident-response step for a bad deploy).
  7. Adopteddevelop (auto-deploys to dev) → main (manual-triggered production deploy) is the live branch strategy; see Git Setup & Workflow for the day-to-day mechanics, including the "trigger CI without a real change" pattern and when main should (and shouldn't) be fast-forwarded (see NFX-024).
  8. Documented — the shared-Supabase-backend caveat is in §4c ("Hidden assumption to flag explicitly"): dev, UAT, and production all currently write to the same backend project.
  9. LoggedNFX-025 records the "no CI/CD pipeline existed" gap and its resolution now that steps 7–22 are live.
  10. Fixedguides/deployment.md §4 now describes the real Cloudflare Pages + GitHub Actions pipeline; the Hostinger reference is gone.

7. Test Summary

Added after the initial CI rollout, once the suite reached 100 files / 1,352+ tests: raw console/log output gets slow to scan for "what actually broke and where" at that size, and only grows slower from here. .github/scripts/test-summary.cjs closes that gap without adding a new test-running dependency — it reads Vitest's own built-in json reporter output (emitted alongside the normal console reporter via --reporter=default --reporter=json --outputFile=test-results.json in the Test step) and renders a Markdown table directly to the GitHub Actions Job Summary page for every CI run.

What it shows — one row per test file (not per individual test, which would be unreadable at this scale): Status, Feature, Test Suite, Total, Passed, Failed, Skipped, Time.

  • Feature is derived from this repo's own tiers/{tier}/features/{feature}/... folder convention (a nested edge-builder/{sub-feature} path resolves to the sub-feature name) — read off the real architecture, not guessed or hardcoded per file.
  • Test Suite links to the exact file at the exact commit on GitHub (GITHUB_REPOSITORY + GITHUB_SHA), so a failing row is one click from the source.
  • Sorted failures-first — the whole point is surfacing problems without scrolling past 90 green rows first.
  • Failure messages (or a whole file's parse/collection error) sit inside collapsible <details> blocks per row, so the table itself stays compact even when several suites are failing at once.
  • Runs via if: always() in ci.yml, so it still renders when the Test step itself fails — that's the case it's most useful for. It never affects the job's own pass/fail status: this step always exits 0, it's additive reporting, not a gate.
  • Coverage is deliberately not a column here — see dev tracker NFX-019: coverage enforcement is currently non-blocking and far below its documented 70/80/65/70 target, so featuring a coverage number next to a row of green checkmarks would misrepresent it as a settled, trustworthy metric before that gap is actually closed.
  • Falls back to printing the same table to the console when GITHUB_STEP_SUMMARY isn't set, so the identical script also works for a manual local run:
    bash
    npm run test -- --reporter=default --reporter=json --outputFile=test-results.json
    node .github/scripts/test-summary.cjs test-results.json