Skip to content

Repository files navigation

practiq-api

Main

Adaptive learning/practice platform API (Java 21 · Micronaut 4.10 · PostgreSQL 16).

Local workflow (from cold)

The full loop from nothing running to a seeded, queryable app:

docker compose up -d                       # 1. Postgres 16 on localhost:5432
./gradlew run                              # 2. start the app — Flyway applies migrations at boot
docker exec -i practiq-api-postgres-1 \
  psql -U practiq -d practiq < src/main/resources/db/seed_local.sql   # 3. load sample data

Then hit the app:

curl http://localhost:8080/health
curl http://localhost:8080/api/v1/concepts

Order matters between steps 2 and 3. Flyway runs the migrations at application startup, not during the build — so the tables don't exist until the app has booted at least once against the Compose DB. Seeding before the first ./gradlew run fails with "relation does not exist". After that first boot the schema persists in the Compose volume, so on later loops you can seed anytime the container is up.

./gradlew run holds the terminal (it runs the server in the foreground), so run the seed and curl commands from a second terminal — or start the app from IntelliJ instead (see Running/debugging from IntelliJ, and remember the MICRONAUT_ENVIRONMENTS=local it requires). Stop the app with Ctrl+C; stop Postgres with docker compose down (add -v to also wipe the data + schema for a clean replay).

The sections below expand each step: Running locally for the app, Local development data for seeding, and Querying the local database for inspecting it.

Running locally

Start the Compose Postgres, then run the app:

docker compose up -d            # Postgres 16 on localhost:5432
./gradlew run                   # serves http://localhost:8080

./gradlew run defaults to the local environment (application-local.properties), so the app connects to the Compose database. Verify it's up:

curl http://localhost:8080/health

The local default is wired into the run task in build.gradle.kts. Without it, the base config has no datasource URL and Micronaut Test Resources would start a throwaway Postgres container instead of using your Compose DB.

Running/debugging from IntelliJ

Running the application's main class directly in IntelliJ (e.g. to attach the debugger) bypasses the Gradle run task, so the local environment is not activated automatically. Add an environment variable to the run configuration:

MICRONAUT_ENVIRONMENTS=local

Without it, the app starts with no datasource URL and Test Resources spins up a throwaway Postgres container instead of connecting to your Compose DB.

Local development data

src/main/resources/db/seed_local.sql holds sample DBRows (concepts, and more data types as they're added) for manually inspecting the app and exercising endpoints like GET /api/v1/concepts.

It is not a Flyway migration — it sits outside db/migration/, so Flyway never runs it. Seeding is deliberately separate from schema: real content will be AI-generated and human-reviewed through the ingestion pipeline, so it doesn't belong baked into migrations. Tests generate their own data and don't use this file.

Load it (Compose Postgres must be up):

docker exec -i practiq-api-postgres-1 psql -U practiq -d practiq < src/main/resources/db/seed_local.sql

The script is idempotent — every insert uses ON CONFLICT DO NOTHING, so you can run it as often as you like (after migrations, after a rebuild, whenever) without duplicate-key errors. Re-running picks up newly added DBRows and leaves existing ones untouched. New data types get their own section in the same file following the same pattern, so loading everything stays a single command.

If you want a clean slate — e.g. to drop DBRows that are no longer in the file — reset first, then reload:

docker exec -i practiq-api-postgres-1 psql -U practiq -d practiq -c 'TRUNCATE concept CASCADE;'

Querying the local database

To inspect the Compose Postgres directly — checking what a migration produced, eyeballing seed data, confirming the status=approved filter has DBRows to filter — run psql inside the container. No local Postgres client needed; the image already ships one.

One-off query with -c (runs, prints, exits):

docker exec -it practiq-api-postgres-1 psql -U practiq -d practiq \
  -c "select status, count(*) from question group by status;"

Interactive shell for a poking-around session:

docker exec -it practiq-api-postgres-1 psql -U practiq -d practiq

Inside the shell, the usual psql meta-commands help: \dt lists tables, \d question describes one, \x toggles expanded (row-per-line) output for wide DBRows, \q quits. select * from flyway_schema_history; shows which migrations have been applied.

The -U practiq -d practiq flags are the local user and database from docker-compose.yml; practiq-api-postgres-1 is the container name Compose generates (docker ps if yours differs). Use -it for the interactive shell and for -c queries you want to read in the terminal; -i alone is enough when piping SQL in from a file, as the seed-load command above does.

Logging

Logging runs through SLF4J (the API called in code) backed by Logback (the implementation). Both ship with Micronaut — nothing to add. Two concerns live in two places:

  • Format and destinationsrc/main/resources/logback.xml: one console appender, root level="info". This is where the line pattern and appenders are defined.
  • Levels — set per environment in application*.properties via logger.levels.<name>=<level>. Micronaut applies these to Logback at startup, so the local dev loop can be noisy while tests stay quiet, without touching the XML.

Adding a logger in code

With Lombok, annotate the class with @Slf4j and use the generated log field:

@Slf4j
@Singleton
public class ConceptService {
    public Concept findById(UUID id) {
        log.debug("looking up concept {}", id);   // parameterised — never string concat
        ...
    }
}

The logger's name is the fully-qualified class name (com.practiq.service.ConceptService), which is what the level config below targets.

Changing levels

A level switches on that severity and everything above it: TRACE < DEBUG < INFO < WARN < ERROR. Setting a package to DEBUG shows DEBUG/INFO/WARN/ERROR from it.

Global floor — change the root in logback.xml:

<root level="info">          <!-- debug for a firehose, warn for near-silence -->

Per-package, per-environment — add to the relevant properties file. To see DEBUG from your own code in the local dev loop only, add to application-local.properties:

logger.levels.com.practiq=DEBUG

Tests and every other environment inherit the root info, so this doesn't make test output noisy. Scope it tighter when chasing one thing — logger.levels.com.practiq.service.ConceptService=TRACE targets a single class.

Switching on framework logging

The same logger.levels.* keys turn on framework internals while diagnosing. Add them to application-local.properties while you need them, then remove:

logger.levels.org.hibernate.SQL=DEBUG               # generated SQL statements
logger.levels.org.hibernate.orm.jdbc.bind=TRACE     # bound parameter values
logger.levels.io.micronaut.http.client=DEBUG        # outbound HTTP (extractor client, later)
logger.levels.org.flywaydb=DEBUG                    # migration execution

Inbound request access logging is separate (handled by Netty, not a logger level) — enable with micronaut.server.netty.access-logger.enabled=true when needed.

Levels can also be set via environment variable for a one-off run without editing files (dots become underscores, uppercased):

LOGGER_LEVELS_COM_PRACTIQ=DEBUG ./gradlew run

API

All routes are versioned under /api/v1. Responses are JSON with nulls included (micronaut.serde.serialization.inclusion=always), so a field's absence is a contract change, not a data artefact.

Endpoint Returns
GET /health liveness
GET /api/v1/concepts all concepts, created_at ascending — bare array (deliberately unpaged)
GET /api/v1/concepts/{id} one concept, or the 404 envelope
GET /api/v1/questions paginated, filterable question catalogue — see below

GET /api/v1/questions

Serves the student catalogue: only APPROVED questions that are linked to at least one concept (an unlinked question is unprocessed and never student-visible). Query params:

  • types — CSV of SHORT_ANSWER|EXTENDED|MCQ
  • difficulties — CSV of numeric codes 1..5 (1(TRIVIAL)5(VERY_HARD))
  • conceptId — questions linked to that concept
  • page / size — zero-indexed page and page size (default 10, capped at 50)

Filters only ever narrow the result; status is not a parameter. Ordering is a server-enforced total order (created_at, then id as tiebreak) so pages are stable and rows can't repeat or vanish across a page boundary.

Paged responses use an envelope; unpaged collections (concepts) deliberately don't:

{
  "content": [
    {
      "id": 7,
      "body": "State Newton's first law.",
      "difficulty": {
        "value": 3,
        "code": "MEDIUM"
      },
      "type": "EXTENDED",
      "createdAt": "2026-06-29T10:15:30Z",
      "linkedConceptIds": [
        10,
        11
      ]
    }
  ],
  "page": 0,
  "size": 10,
  "totalCount": 1
}

difficulty serialises as {value, code} (whole object null when unrated); nominal enums (type) serialise as their bare code. Provenance fields (source, status, source_spec) are deliberately not exposed to students.

Error handling

All errors aim to return one envelope: {"error": "...", "status": <code>} (see dto/ErrorResponse). Each case is an ExceptionHandler in exception/; Micronaut routes a thrown exception to the most specific handler registered for its type.

Current coverage:

  • 400ConversionErrorExceptionHandler (binding/conversion failures; replaces Micronaut's default). For enum-typed params it enumerates the legal values, e.g. ?types=BAD"types: must be one of SHORT_ANSWER, EXTENDED, MCQ" and ?difficulties=9"difficulties: must be one of 1(TRIVIAL), 2(EASY), …".
  • 404NotFoundExceptionHandler (unmatched route / missing resource).
  • 422ConstraintViolationExceptionHandler (bean-validation failures on an otherwise-parseable request, e.g. @UniqueElements duplicates), one message per violation, sorted and joined.
  • 500GenericExceptionHandler catches any otherwise-unhandled Exception as a last-resort safety net: consistent envelope, logged at ERROR, no internals leaked. (An escaping OptimisticLockException currently lands here too — pinned by a CT; the first write endpoint should introduce a dedicated 409 Conflict handler.)

One deliberate asymmetry, pinned by CTs: Pageable params never 400. Micronaut's binder is lenient — ?page=abc or ?size=0 silently fall back to the defaults, and ?size=500 is capped at the configured maximum (50) — whereas filter params (?conceptId=abc) fail loudly with the 400 envelope. Making paging params strict would need a custom Pageable binder; not worth the machinery yet.

Formatting

Formatting is owned by Spotless using palantir-java-format. There is no house style to learn and nothing to argue about in review — the formatter decides.

./gradlew spotlessApply     # fix formatting
./gradlew spotlessCheck     # report violations (runs as part of `check` / `build`)

Enable the pre-commit hook

Do this once per clone:

git config core.hooksPath .githooks

Git never clones hook configuration — only the scripts in .githooks/ travel with the repo, and the setting that points git at them lives in .git/config, which doesn't. So a fresh clone has the hook sitting there inert until the command above is run.

The hook runs spotlessApply before each commit. If it changed nothing, the commit proceeds. If it did change something, the commit is aborted and the reformatting is left unstaged in your working tree, with the affected files listed. Review it, stage it, commit again.

It deliberately never stages anything for you. An auto-staging hook puts changes into a commit that nobody looked at, and if you had staged a file partially (git add -p), re-adding the whole file after reformatting would silently drag in the hunks you meant to leave out.

Bypass with git commit --no-verify when you need to.

The hook is a convenience, not a gate — it can be skipped, unset, or never enabled. CI is the enforcement: the pull request pipeline runs spotlessCheck as its own gate, so badly formatted code fails regardless of anyone's local setup.

Testing

Four tiers. The guiding rule: put each test where it can actually observe the behaviour it claims to verify. Mocking everything around a thin layer just tests a tautology.

Tier Suffix Wires Boundary Task
Unit *Test one class, no context all deps mocked (Mockito) test
Component *CT real web layer (routing, binding, validation, serialization) repository mocked, no DB test
Integration *IT full stack real Postgres (Testcontainers) integrationTest
Performance *PT full stack real Postgres (Testcontainers) performanceTest
./gradlew test                  # unit + component (*CT) — the every-change loop
./gradlew integrationTest       # integration (*IT) — real Postgres, pre-merge / CI
./gradlew performanceTest       # performance (*PT) — real Postgres, per-request query counts
./gradlew build                 # runs everything

CI does not use build. It runs each gate as a separate named step so one failure doesn't hide the rest — see .github/workflows/pull-request.yml.

main is protected by a repository ruleset requiring the Pull request checks job to pass, plus linear history and no force-pushes or deletion. A copy lives at .github/rulesets/main-protection.json for reference only — GitHub stores the live rule, and nothing in this repo applies or verifies it. Re-import it from the ruleset page if it ever needs restoring.

What each tier answers

How a test is wired tells you where it plugs in. Only the question tells you whether it's worth having — so that's what the name encodes:

Naming Question it answers
*Test Does this logic do what it should?
*CT Does the web layer bind, serialise and map correctly?
*ControllerIT Does the definition of done actually hold, end to end?
*RepositoryIT / *SpecificationFactoryIT Do I understand the method I'm calling?
*DatabaseIT Does the migration say what I think it says?
*PT Is the query plan the shape I think it is?

Three IT flavours, one tier. *ControllerIT, *RepositoryIT and *DatabaseIT share a tier because they share a mechanism — they need a container and run in integrationTest. They differ in the question, which the name carries. IT here honestly means "needs Docker", which is true of all three; "integration" is a conceptual promise the suffix never made.

No tier substitutes for another. A repository test proves you understand your tools; it does not prove the app calls them. Swap findOne(spec) for a hand-rolled query and the repository test still passes — it now tests a method production abandoned, and it goes stale silently. Only the controller IT's meaning survives that refactor.

Why there's a performance tier

*PT asserts how the data was fetched, not what came back. Each endpoint gets a happy-path pin on the number of JDBC statements a single request fires; row-scaling endpoints also assert the count doesn't grow with the number of rows.

That second assertion catches an N+1. The first one exists for a subtler bug. Reintroduce a fetch-join on a paginated to-many and Hibernate quietly stops pushing LIMIT to SQL — it fetches the whole result set and pages it in memory. Every correctness test still passes, because the rows returned are correct; the invariance check also passes, because the statement count stays constant. The only thing that moves is the absolute number of statements. Tests that assert what came back structurally cannot see this. That's the whole reason the tier exists.

The counts are deliberate magic numbers, each with a comment saying what it's made of. Update the pin consciously when you legitimately add a query — the friction is the feature.

Calibration

This is a practice project, and some of this is more than a project this size needs. That's deliberate. How much test weight is right is a function of team preference and how much the thing costs when it breaks; the point here was to build the designs out far enough to have an opinion about them. Too much is sometimes how you find out what enough looks like.

Pipeline note: *PT runs as part of the test step on every pull request. Under a git-flow dev/main split it may be cleaner to move it to a later stage rather than paying the container cost on every push. Easily reversed.

Writing a component test. Micronaut has no @WebMvcTest-style slice annotation, so a few things have to be arranged by hand to test the web layer without a database. The pattern lives in ConceptControllerCT + src/test/resources/application-ctslice.properties:

@ComponentTest                                  // bundles @MicronautTest(transactional = false, environments = "ctslice")
class ConceptControllerCT {

    @Inject
    EmbeddedServer embeddedServer;       // for RestAssured.port
    @Inject
    ConceptRepository conceptRepository; // the mock, for stubbing

    @MockBean(ConceptRepository.class)
    ConceptRepository conceptRepository() {
        return mock(ConceptRepository.class);
    }

    // when(conceptRepository.listOrderByCreatedAtAsc())...; then GET /api/v1/concepts over
    // real HTTP (RestAssured) and assert on the JSON body, not by deserializing into Concept.
}

Why each piece is needed:

  • transactional = false@MicronautTest otherwise wraps each test in a rollback transaction, and beginning that transaction opens a JDBC connection (even though the repository is mocked). This is the maintainer-recommended fix.
  • environments = "ctslice" — the ctslice properties file supplies a no-op datasource and, crucially, test-resources.containers.postgres.enabled=false so Test Resources does not start a Postgres container. It also stops Hikari connecting eagerly and Hibernate probing JDBC metadata at boot, so the context starts with no database at all.
  • Assert on the wire format, not the entityConcept is @Getter-only (no setters), so deserializing the response back into Concept leaves fields null. Retrieve into a Map/String and assert the JSON, which is what the endpoint actually returns.

Test bodies are scaffolded with fail("not yet implemented") so unwritten coverage shows red rather than a misleading green.

Provoking framework-level failures (test-scoped controller). A global exception handler (e.g. the UnsatisfiedRouteException → 400 envelope handler) is a cross-cutting concern, not the property of any one endpoint. Triggering it through a real app controller couples the error-handling test to that controller's unrelated behaviour. Instead ErrorHandlingCT owns a ErrorHandlingTestController whose only job is to fail on demand — a missing required @Header, a missing @QueryValue, a thrown RuntimeException, etc. It is scoped to that one test with the Micronaut spec.name idiom so it never pollutes other contexts:

@Requires(property = "spec.name", value = "ErrorHandlingCT")   // on the test controller
@Controller("/test/errors") ...

@Property(name = "spec.name", value = "ErrorHandlingCT")        // on the test class
class ErrorHandlingCT { ...
}

Only the context whose spec.name matches loads the controller. Handling the abstract UnsatisfiedRouteException (not the header-specific subclass) means one handler serves every missing-binding flavour; the CT proves that breadth by exercising two siblings (header + query value) rather than every subtype.

Writing an integration test. *IT tests drive the full stack over real HTTP against a real Postgres, and arrange their data with raw SQL, not the repositories — so a failure points at the code under test, not at the persistence code used to set it up (and we avoid adding production methods like deleteAll purely for tests). The helper is utils.data.TestDatabase (insert(table, Map<col,value>) / update(table, id, col, value) / clear(table)); the pattern lives in ConceptControllerIT:

@IntegrationTest                                // bundles @MicronautTest(transactional = false)
class ConceptControllerIT {

    @Inject
    TestDatabase testDatabase;
    @Inject
    EmbeddedServer embeddedServer;

    @BeforeEach
    void setUp() {
        testDatabase.clear("concept");                  // global TRUNCATE ... RESTART IDENTITY
        RestAssured.port = embeddedServer.getPort();
    }
    // insert(...) fixtures, then GET over HTTP and assert order-agnostically.
}

Getting TestDatabase to hand out genuinely independent connections took three things that only make sense together — the injected DataSource is Micronaut's connection-managed wrapper, not a raw pool:

  • transactional = false@MicronautTest otherwise wraps each test in a managed transaction, and getConnection() then returns that transaction's connection. Inserts would join the test transaction: uncommitted, invisible to the HTTP request (which runs on another thread), and rolled back at the end. Turning it off removes the entanglement.
  • DataSourceResolver.resolve(dataSource) (in TestDatabase) — with no managed transaction, the wrapped DataSource has no connection scope to draw from and throws NoConnectionException. Resolving to the underlying Hikari pool yields plain connections that need no ambient scope. (It's still the pool — just without the management layer.)
  • connection.setAutoCommit(true) — the resolved pool still hands out autoCommit=false connections (Hibernate's default), so an insert that doesn't commit is rolled back when the connection returns to the pool. Autocommit makes "committed before the request runs" true unconditionally.

Because id and created_at are generated by the database, the shape tests assert their shape (everyItem(greaterThan(0)), an ISO-8601 pattern) rather than fixed values, and look DBRows up by name (find { it.name == … }) instead of by position — see Integration tests share one database for why position isn't assumed there.

The list endpoint itself does have an ordering contract — GET /api/v1/concepts returns created_at asc. That contract is pinned separately by getConceptsReturnsInCreatedAtOrder, which re-stamps one row's created_at (via TestDatabase.update) to prove the order isn't incidental. The shape tests staying order-agnostic is a deliberate split: they verify what comes back, the ordering test verifies in what order.

test still needs Docker — a deliberate compromise

Component tests no longer start a Postgres container (the ctslice env disables it), but ./gradlew test still requires Docker. This is a conscious trade-off, not an oversight, so the reasoning is recorded here.

Why it can't be fully removed cheaply. The Micronaut Gradle plugin attaches the Test Resources service to every Test task. That service is a separate JVM that connects to Docker and starts a ryuk reaper container as soon as it boots — before any test runs, regardless of whether a test needs a resource. The plugin's enabled flag is project-wide (there is no per-task toggle), so you can't disable it for test while keeping it for integrationTest within one module. Our ctslice trick removes the Postgres container; it cannot remove the service + ryuk.

Why we keep Test Resources anyway. It's the part that scales. It provisions containers declaratively, supports a shared server, and ships modules for the resources we'll add next (LocalStack/S3 in Sprint 1.2) and across future services. Hand-rolling Testcontainers per *IT (or per service) to win a Docker-free test would trade a small, well-understood cost for boilerplate that grows with every new IT class, resource type, and microservice. That's optimising the wrong axis.

The real fix, when it's worth it. A genuinely Docker-free fast loop needs Test Resources isolated to an integration module (a separate Gradle module — or a shared convention plugin once there are multiple services — that applies the plugin, while the unit/component module does not). A separate source set alone is not enough, because the plugin is project-global.

Decision: deferred. With a single *IT and ~1s of ryuk startup, the module split is premature and cuts against the monolith-first stance in CLAUDE.md §3. Revisit when the weight justifies it — any of: several *IT classes, LocalStack/S3 arriving (Sprint 1.2), or the first real service extraction (Phase 4, where shared build conventions get set up anyway). Tracked as TODO(test-resources) in build.gradle.kts.

What the ctslice + @ComponentTest machinery buys us today is still real: component tests do no DB work and express the intended boundary (web layer in, persistence mocked). We keep it as the design statement, and the module split later turns "no Postgres container" into "no Docker at all" without changing how the tests are written.

*IT tests use a real Postgres 16 via Micronaut Test Resources, so Docker must be available for integrationTest.

Integration tests share one database — keep them sequential

Test Resources starts one Postgres container for the whole build, and every *IT connects to it. Each test resets state itself (truncate + insert its own fixtures in @BeforeEach), and that reset is global — it clears the whole table, not just the calling test's DBRows.

That means *IT tests must run sequentially, which they do by default: Gradle's maxParallelForks is 1 and JUnit 5 parallel execution is off. Don't enable either for integrationTest. If you do, tests race on the shared table — one test's truncate wipes the DBRows another just inserted and is about to read. And because read endpoints return the whole table and assertions pin the exact set returned (containsInAnyOrder), a concurrent writer's DBRows break the assertion no matter how surgical the cleanup is. Whole-table read + exact-set assertion is fundamentally incompatible with concurrent writers on the same table.

If the IT suite ever grows enough that speed matters, the fix is not raw parallelism: either keep DB-touching tests serial with @ResourceLock / @Execution(SAME_THREAD) and parallelise only the fast (*Test/*CT) tiers, or give each worker its own database/schema. Both are deferred — the IT tier is deliberately kept small (see §9 of CLAUDE.md), so sequential is correct for now.

Continuous integration

Three workflows, deliberately separate because they answer different questions.

Workflow Trigger Question
pull-request.yml PR against main Is this change safe to merge?
main-push.yml push to main Is main healthy, and what needs cleaning up?
codeql.yml PR, push to main, weekly Is there a known vulnerability pattern in the code?

Everything is a named step

Neither workflow runs ./gradlew build. Each gate is invoked explicitly:

spotlessCheck                              formatting
compileJava compileTestJava                Error Prone (a javac plugin — it has no task of its own)
test integrationTest performanceTest       all three test tiers
shadowJar                                  the artefact

build would run all of these via check, but as one opaque task. Naming them separately means a failure is attributed to a gate rather than to "the build", and the gates that can still run after a failure do.

One push reports every failure it can reach

Each gate carries if: ${{ !cancelled() }}.

A step's implicit condition is success(), which is false once anything has failed — that's why a normal pipeline skips everything after the first failure. Overriding it with !cancelled() removes the failure check while leaving the failure itself intact, so the remaining gates run and the job still goes red. Use !cancelled() and not always(): the latter also runs when you cancel the run, which makes a stuck pipeline unkillable.

continue-on-error: true looks like the same thing and isn't. It rewrites the step's conclusion to success, which hides the failure from the job as well as from the following steps, so the verdict then has to be reconstructed by hand from each step's outcome.

Tests and artefact are the exception — they additionally require steps.errorprone.outcome == 'success', because there is no binary to test or package if compilation failed. That is a real dependency, not bookkeeping.

The test step passes --continue for the same reason at the Gradle level: without it Gradle stops at the first failing task, so a unit failure would hide every integration and performance result.

Formatting and static analysis are not the same gate

spotlessCheck reads source text and reports regardless of whether the code compiles. Error Prone runs inside compilation. So a badly formatted file with a compile error reports both in a single run — which is the whole point of the arrangement above.

Error Prone errors fail the build. Warnings do not; they are collected on main instead (below).

Error Prone warnings become one sticky issue

main-push.yml tees the compile output and hands it to .github/scripts/error_prone_report.py, which maintains a single GitHub issue titled Error Prone warnings:

  • warnings present, no issue → create it, labelled code-quality and assigned, so the notification email actually arrives
  • warnings present, issue exists → update the body in place, reopening it if it had been closed
  • no warnings → close it

The issue is found by label, never by title, and pull requests are filtered out of the result — the /issues endpoint returns PRs as well, so a matching PR title would otherwise be patched instead. The code-quality label must exist before the first run.

Warnings are deliberately not gated on a PR. They are a cleanup backlog, not a blocker, and the issue is the backlog.

shell: bash is required wherever output is piped

GitHub's implicit default shell is bash -ewithout pipefail. So ./gradlew … 2>&1 | tee errorprone.log reports tee's exit code, which is always 0, and a failed compile passes silently. Naming the shell explicitly gets --noprofile --norc -eo pipefail:

defaults:
  run:
    shell: bash

main-push.yml needs this because it captures the log. pull-request.yml does not pipe and so does not set it.

Security scanning

codeql.yml runs CodeQL over the Java sources. It uses build-mode: none, which extracts from source rather than compiling — a compiling build would mean a second full Gradle run with all five annotation processors, roughly doubling CI time for no extra coverage.

It runs on its own workflow rather than as a step in the other two, for three reasons: no existing workflow has the right trigger set (the weekly cron would otherwise drag the whole test suite along with it), security-events: write stays scoped away from the token that runs Gradle, and a separate workflow runs concurrently instead of adding to the critical path.

The weekly cron matters because CodeQL's query packs are updated regularly — unchanged code can still surface a new alert.

Findings block merges via the ruleset's code scanning rule, not by failing the workflow. A run succeeds whether or not it finds anything; finding something isn't a workflow failure. Current thresholds are medium_or_higher for security severity and errors_and_warnings for alert severity.

Known limitation: CodeQL does not understand Micronaut endpoints

Taint tracking on request-bound parameters does not work on this codebase. Verified, not assumed: a deliberate path injection — a request field concatenated into a path and passed to Files.delete — produced zero alerts, with java/path-injection confirmed present among the queries that ran.

The sink is modelled correctly. The source is not. CodeQL ships 406 data-extension model files and 70 framework libraries for Java; none of them cover Micronaut. Without a modelled source, nothing is attacker-controlled, no taint flows, and the query cannot fire. The same code under Spring would be flagged immediately.

Support exists upstream but is unmerged: github/codeql#21387, open since February 2026. Its five .model.yml files could be vendored as a local model pack, but they only model methods on HttpRequest — annotation-bound parameters (@Body, @RequestBean, @QueryValue) need the QL library changes in the same PR, and those modify core files that a model pack cannot override. This codebase binds input exclusively through annotations, so vendoring the data alone would buy nothing.

CodeQL is kept because the source-independent queries still apply — hardcoded credentials, weak crypto, insecure randomness, XXE. It is not the endpoint-injection scanner it appears to be. Re-test when #21387 merges.

Branch protection

main is protected by a repository ruleset (main-protection), not classic branch protection. It requires the Pull request checks job to pass, blocks merges on CodeQL findings above the thresholds above, and enforces linear history while blocking force-pushes and deletion. Reviews are not required — a solo repo cannot approve its own PR.

Linear history means the merge button offers squash and rebase only.

The ruleset lives in GitHub, not in this repository; a copy is kept at .github/rulesets/main-protection.json for reference only, and nothing here applies or verifies it. That gap is not theoretical: the required check once pointed at a job name that had been renamed hours earlier, which made main unmergeable with nothing in any diff to show why.

The same applies to the repository security toggles — Dependabot alerts and security updates, secret scanning, push protection — recorded at .github/security-settings.json. Declaring all of this properly is a Terraform job for practiq-infrastructure when that exists.

Dependabot

.github/dependabot.yml configures version updates — weekly bumps for github-actions and gradle. Security updates are a separate mechanism, enabled in repository settings, and fire whenever a dependency you already use gains a published advisory; they need no entry in that file.

One trap: ignore rules in dependabot.yml apply to security update PRs as well as version ones. Silencing a noisy dependency there silently silences its security fixes too. There are no ignore rules today.

Micronaut 4.10.16 Documentation


Feature jdbc-hikari documentation

Feature flyway documentation

Feature management documentation

Feature lombok documentation

Feature validation documentation

Feature serialization-jackson documentation

Feature test-resources documentation

Feature micronaut-aot documentation

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages