- Java 21 (managed via jenv)
- Node.js (downloaded automatically by the Gradle build)
- Docker (required — local dev and the test suite start PostgreSQL via Quarkus Dev Services)
- Minikube
- kubectl
Gradle itself is not a prerequisite — the repo ships a Gradle wrapper
(./gradlew and ./gradlew.bat at the repo root, with the version pinned by
gradle/wrapper/gradle-wrapper.properties). On first run ./gradlew
downloads the right Gradle version; subsequent runs reuse it.
The project is a single Gradle multi-project build covering domain, backend, and frontend.
# Full build (domain + backend + frontend, all tests)
./gradlew build
# Build, skipping tests
./gradlew build -x test
# Domain (KMP) build + tests only
./gradlew :domain:build
# Backend unit + @QuarkusTest (PostgreSQL via Dev Services — Docker required)
./gradlew :backend:implementation:test
# Frontend type-check + ESLint
./gradlew :frontend:check
# Run end-to-end tests (requires a running backend)
./gradlew :backend:end2end:e2eTestCI does not run :backend:end2end:e2eTest. HealthEndpointIT reads
System.getProperty("backend.url", "http://localhost:8080") — it defaults to
8080, while the dev backend serves 8090 — and the e2eTest task in
src/backend/end2end/build.gradle.kts declares no systemProperty, so a
-Dbackend.url=… on the Gradle command line never reaches the forked test JVM.
Wiring systemProperty("backend.url", …) into that task is the prerequisite for
putting the suite into the pipeline; until then its single test only asserts
/q/health is UP, which the pipeline's readiness poll already proves.
.github/workflows/ci.yaml is the public quality gate. It runs on every pull
request and every push to main, needs no secrets, and deploys nothing.
| Job | When | What it does |
|---|---|---|
build |
PR + main |
Validates the committed Gradle wrapper jar against Gradle's published checksums, then ./gradlew build — domain (JVM + Kotlin/JS), backend @QuarkusTest, :frontend:check. Then ./gradlew staticAnalysis and an upload of the consolidated report plus the JUnit XML. |
e2e |
PR + main |
Starts the dev stack with ./scripts/dev.sh and runs the Playwright suite against it. |
images |
main only |
Builds both container images (with --no-build-cache, or a cached augmentation skips the Jib side effect and no image is produced), asserts both exist, checks the Jib timestamp flags and digest-pinned base image, and scans the backend with Trivy. Pushes nothing. |
security |
PR + main |
npm audit --omit=dev and a Trivy filesystem scan. Reports only — it does not gate. |
To reproduce the gate locally, run what the pipeline runs:
./gradlew build
./gradlew staticAnalysisThe runner needs Docker: the %test profile starts a throwaway PostgreSQL 16
through Quarkus Dev Services, and the e2e job's dev stack does the same.
Deployment is not here. It runs on a separate internal pipeline whose coordinates are deliberately kept out of this repository (task-028).
That is the OpenAPI drift gate (task-114). It is opt-in via the CI
environment variable — which GitHub Actions sets automatically — so it fires in
the pipeline but not during ordinary local development. A failure means the
committed API client no longer matches the backend's REST contract, usually
because a DTO changed. The fix is never to disable the gate:
./gradlew build
git add src/frontend/src/lib/api/openapi.json src/frontend/src/lib/api/schema.d.tsThe build runs on the Gradle 9.6.1 wrapper. Our own build scripts are
deprecation-free (./gradlew build -x test --warning-mode all reports nothing
from any *.gradle.kts in this repo). The wrapper is intentionally not
advanced to Gradle 10 yet, because three deprecations remain — all emitted
inside third-party plugins, none of which has a released version that fixes
them (verified 2026-07; Gradle 10 turns these into hard errors):
| Deprecated API | Emitted by | Status |
|---|---|---|
ReportingExtension.file(String) |
detekt (io.gitlab.arturbosch.detekt) DetektPlugin.apply |
1.23.8 is the latest release and still calls it; no fix available. detekt 1.23.8 also caps the supported Kotlin version, so it constrains any Kotlin/Quarkus bump. |
Project.getProperties (will error in Gradle 10) |
Quarkus Gradle plugin (io.quarkus) ApplicationDeploymentClasspathBuilder |
The latest Quarkus (3.37.1, tested) still calls it; bumping from 3.35.1 gives no benefit, so we stay on the validated 3.35.1. |
Configuration.getTaskDependencyFromProjectDependency |
Kotlin Multiplatform plugin (addDependsOnTaskInOtherProjects) |
From the Kotlin Gradle plugin (2.3.21); no compatible fixed release (the latest stable is beta-adjacent and would break detekt 1.23.8). |
When to revisit: once detekt ships a Gradle-10-compatible release (that also
supports a newer Kotlin) and Quarkus/KMP clear their calls, bump those plugins
(Kotlin + Quarkus BOM in lockstep — see the ICE note in the Gradle-build memory),
run ./gradlew build -x test --warning-mode fail to confirm zero deprecations,
then advance the wrapper to Gradle 10.
./gradlew staticAnalysis is the single goal that runs every static-analysis
tool across all modules in one invocation: detekt over the Kotlin modules
(:domain, :backend:implementation) plus svelte-check and ESLint over the
frontend (via the lint:report script). It runs no tests and needs no Docker.
It also produces a consolidated, project-wide report at
build/reports/static-analysis/static-analysis.html (plus a merged
static-analysis.sarif) covering detekt and ESLint together. This is generated
by a dependency-free Node script (scripts/sarif-to-html.mjs) run through the
gradle-node plugin's managed Node — so it needs no extra install (no Python, no
Docker). svelte-check has no machine-readable report and stays a console-only
gate, so it is not part of the consolidated report.
As per-layer alternatives, ./gradlew detekt (Kotlin only) and
./gradlew :frontend:check (frontend svelte-check + ESLint) cover one layer
each; all of these also run as part of ./gradlew build. The reports are
currently informational — the build stays green regardless of how many
findings each tool produces. A follow-up task can flip the gates to enforcing
once the existing backlog has been triaged.
| Layer | Tool | Task | Reports |
|---|---|---|---|
| Kotlin — backend | detekt | :backend:implementation:detekt |
src/backend/implementation/build/reports/detekt/detekt.{xml,html} |
| Kotlin — domain | detekt | :domain:detekt |
src/domain/build/reports/detekt/detekt.{xml,html} |
| TS / Svelte / HTML | ESLint | :frontend:npmLintReport |
src/frontend/reports/eslint.{json,html} |
| All (consolidated) | Node | :frontend:sarifHtmlReport |
build/reports/static-analysis/static-analysis.{html,sarif} |
Both detekt scopes share config/detekt/detekt.yml (top-level config
with buildUponDefaultConfig: true). The frontend's ESLint flat config
lives at src/frontend/eslint.config.js and composes
typescript-eslint (non-type-checked), eslint-plugin-svelte
(parses <script lang="ts"> blocks via tseslint.parser), and
@html-eslint's flat-recommended set for plain *.html.
Fastest iteration loops during a focused fix:
# Backend Kotlin only
./gradlew :backend:implementation:detekt
# Domain Kotlin only
./gradlew :domain:detekt
# Frontend (TS + Svelte + HTML)
cd src/frontend && npm run lint # console output, exits non-zero on findings
cd src/frontend && npm run lint:report # writes reports/eslint.{json,html}, always exits 0The static-analysis tasks add a few seconds per layer. For most workflows this
is fine; if you need a faster loop (e.g. you're iterating on a single test and
don't care about lint output), the per-tool tasks above let you run just what
you need instead of the full ./gradlew build.
Several task validation blocks gate their heaviest checks behind the
environment variable staticCodeAnalysis. Set it to 1 before
running a validation pass when you want detekt/ESLint included; leave
it unset (the default) when you just want the cheap structural greps:
# Cheap validation only (file-exists, grep — milliseconds)
./scripts/task.sh ...
# Include the heavy static-analysis runs
export staticCodeAnalysis=1
./scripts/task.sh ...This convention was introduced in task-056 and applies to the
validation blocks of any task whose work spans the lint pipeline.
Each profile runs one auth module, selected at build time by the
app.auth.provider property (Quarkus @IfBuildProperty) — so the value must
be set when the image is built, not just at runtime:
| Profile | Auth module | app.auth.provider |
|---|---|---|
dev (local) |
dev (static users) | dev |
test |
dev (static users) | dev |
dev-minikube |
Entra (OIDC) | entra |
prod |
Entra (OIDC) | entra |
scripts/minikube-deploy.sh selects Entra by exporting
APP_AUTH_PROVIDER=entra / VITE_AUTH_PROVIDER=entra before the Gradle image
build (./gradlew :backend:implementation:imageBuild). The mapping is
regression-guarded by AuthProviderProfileTest.
Backend and frontend run locally on your machine. The backend uses PostgreSQL started automatically as a throwaway container by Quarkus Dev Services, so Docker must be running.
Quick start (runs both backend and frontend):
./scripts/dev.shOr start them individually:
Start the backend:
QUARKUS_PROFILE=dev ./gradlew :backend:implementation:quarkusDevStart the frontend (in a second terminal):
cd src/frontend
npm run devThe backend is available at http://localhost:8080, the frontend at http://localhost:5173. The frontend proxies API requests to the locally running backend. No Docker or container image is needed.
The authentication is done using a static authentication provider described in authentication.md.
Backend, frontend, and PostgreSQL run as pods on your local Minikube cluster.
Authentication uses the Entra module here (production-like), not the dev
static provider. Export ENTRA_TENANT_ID, ENTRA_API_CLIENT_ID, and
ENTRA_SPA_CLIENT_ID before ./scripts/minikube-deploy.sh — it fails fast if
any is missing. See entra-setup.md for how to obtain the
values and authentication.md for module behaviour.
Quick start (setup, build, and deploy):
./scripts/minikube-setup.sh
./scripts/minikube-deploy.shOr step by step:
-
Set up Minikube with addons:
./scripts/minikube-setup.sh
-
Build and deploy everything:
./scripts/minikube-deploy.sh
-
Access the backend:
kubectl -n estimation port-forward svc/backend 8080:8080
Then open http://localhost:8080/q/health or http://localhost:8080/q/swagger-ui
-
Reset (stop and delete Minikube):
./scripts/minikube-reset.sh
Minikube runs the dedicated dev-minikube profile — the overlay sets
QUARKUS_PROFILE: dev-minikube in the backend-config ConfigMap
(k8s/overlays/minikube/backend-oidc.yaml). That profile gives plain-text
console logs (JSON is only enabled under %prod) and DEBUG for the
application package. Fetch logs from the estimation namespace and the
backend deployment:
kubectl -n estimation logs -f deploy/backend # follow
kubectl -n estimation logs deploy/backend --previous # after a crash-loopThe DEBUG level lives in application.properties, not in a k8s env var, and it
takes two properties:
%dev-minikube.quarkus.log.category."io.pythia".level=DEBUG
quarkus.log.category."io.pythia".min-level=DEBUG # unprofiled — see belowWhy min-level too? .level is a runtime property, but min-level is the
build-time floor below which a category can never log, and it is fixed under the
profile active when the image is packaged — which is prod, not
dev-minikube. Without an unprofiled min-level=DEBUG, the runtime
.level=DEBUG gets clamped back up to the prod-baked INFO floor and DEBUG
lines never appear (this is why %dev works — dev mode augments and runs under
the same profile — but the packaged dev-minikube image did not). Keeping
min-level unprofiled lowers the floor at build time; prod still logs at INFO
because its .level stays INFO.
Why not an env var? quarkus.log.category."io.pythia".level has
dots inside the quoted category name, and an environment variable turns every
dot into _ — Quarkus then can't tell io.pythia from
io_pythia, so QUARKUS_LOG_CATEGORY__IO_PYTHIA__LEVEL is
silently ignored. (Simple keys like QUARKUS_LOG_CONSOLE_JSON_ENABLED map fine.)
Selecting the profile via the dot-free QUARKUS_PROFILE env var and keeping the
dotted category level in application.properties sidesteps the limitation.
Since the level is baked into the image, changing it means a rebuild + redeploy
(./scripts/minikube-deploy.sh). A running pod also won't pick up a changed
ConfigMap until it restarts:
kubectl -n estimation rollout restart deploy/backend
kubectl -n estimation exec deploy/backend -- printenv | grep QUARKUS_PROFILE # verify dev-minikubeTwo sets of committed files are generated by scripts rather than edited by hand. Both
drive Playwright's bundled Chromium so the repository needs no image dependency, and both
outputs are committed so a plain npm run build never needs a browser.
| Command | Generates | Re-run when |
|---|---|---|
npm run gen:icons |
the app icons and site.webmanifest in src/frontend/static/ |
src/lib/assets/logo.svg changes |
npm run gen:screenshots |
the README screenshots in docs/images/ |
the screens they show change materially |
gen:screenshots (src/frontend/scripts/capture-screenshots.mjs) needs a running dev
stack — start ./scripts/dev.sh first, or the script exits non-zero with a message
rather than writing blank images. It captures against the seeded demo data, switches the
capture user's UI language to English first (the README is English, the seed content is
German), and seeds one collaborative session through the REST API so the session shot shows
a revealed round rather than an empty room.