Skip to content

CI/CD Integration

QueryAudit is designed to catch query performance issues before they reach production. By running as part of your test suite, it integrates naturally into any CI/CD pipeline.

How It Works

When fail-on-detection is true (the default), QueryAudit throws an AssertionError on confirmed issues (ERROR or WARNING severity). This causes the test to fail, which in turn causes the CI build to fail -- no extra configuration needed.

BUILD FAILED

OrderServiceTest > findRecentOrders_shouldUseIndex FAILED
    java.lang.AssertionError: QueryAudit detected 2 issue(s) in findRecentOrders_shouldUseIndex:

      [ERROR] N+1 Query detected (table: order_items)
        Detail: Query repeated 12 times (threshold: 3)
        Suggestion: Use JOIN FETCH, @EntityGraph, or batch loading (IN clause)

      [ERROR] Missing index on WHERE column (table: orders)
        Detail: Column 'user_id' is used in WHERE clause but has no index
        Suggestion: CREATE INDEX idx_orders_user_id ON orders (user_id);

GitHub Actions

Basic Setup with MySQL

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: test
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h localhost"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Run tests (including QueryAudit analysis)
        run: ./gradlew test
        env:
          SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/testdb
          SPRING_DATASOURCE_USERNAME: root
          SPRING_DATASOURCE_PASSWORD: test

      - name: Upload QueryAudit reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: query-audit-reports
          path: build/reports/query-audit/
          if-no-files-found: ignore

The if: always() on the upload step

This ensures reports are uploaded even when tests fail, so you can review the QueryAudit output in the build artifacts.

Inline PR annotations + step summary

When GITHUB_ACTIONS=true (set by the runner), QueryAudit emits workflow commands in addition to the console report:

  • ERROR::error, WARNING::warning, INFO::notice
  • Markdown summary appended to $GITHUB_STEP_SUMMARY

Activation is automatic; no workflow changes needed. For a PR comment, pair the JSON report with a github-script step:

      - name: Post QueryAudit summary as PR comment
        if: github.event_name == 'pull_request' && always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const path = 'build/reports/query-audit/report.json';
            if (!fs.existsSync(path)) return;
            // report.json is an array of per-test reports — sum across them.
            const tests = JSON.parse(fs.readFileSync(path, 'utf8'));
            const confirmed = tests.reduce((s, t) => s + (t.summary?.confirmedIssues || 0), 0);
            const info = tests.reduce((s, t) => s + (t.summary?.infoIssues || 0), 0);
            const body = `**QueryAudit**: ${confirmed} confirmed, ${info} info across ${tests.length} test method(s).`;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body,
            });

Report file layout

QueryAudit writes a single aggregate report.json at the configured report.output-dir (default build/reports/query-audit/). The top-level value is a JSON array with one entry per test method — each entry has a summary object (confirmedIssues, infoIssues, acknowledgedIssues, ...) plus confirmedIssues / infoIssues arrays of individual findings. Per-test HTML files (<TestClass>.html) and an index.html aggregate sit alongside.

With PostgreSQL

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd="pg_isready -U test"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Run tests
        run: ./gradlew test
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
          SPRING_DATASOURCE_USERNAME: test
          SPRING_DATASOURCE_PASSWORD: test

      - name: Upload QueryAudit reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: query-audit-reports
          path: build/reports/query-audit/
          if-no-files-found: ignore

With Baseline Files

To use baseline-based regression detection in CI, commit the baseline file and update it explicitly when query counts change intentionally.

      - name: Run tests with baseline
        run: ./gradlew test

      # Only update baseline on main branch merges, not PRs
      - name: Update baseline (main only)
        if: github.ref == 'refs/heads/main' && success()
        run: |
          ./gradlew test -DqueryAudit.updateBaseline=true
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add .query-audit-counts
          git diff --cached --quiet || git commit -m "chore: update query-audit baseline"
          git push

With PR Comment (JSON Report Parsing)

Post a summary of QueryAudit findings as a PR comment:

      - name: Run tests with JSON report
        run: ./gradlew test
        env:
          SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/testdb
          SPRING_DATASOURCE_USERNAME: root
          SPRING_DATASOURCE_PASSWORD: test
        continue-on-error: true

      - name: Comment on PR with QueryAudit summary
        if: github.event_name == 'pull_request' && always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const path = 'build/reports/query-audit/report.json';
            if (!fs.existsSync(path)) return;
            // report.json is an array — one entry per test method. Sum the per-test summaries.
            const tests = JSON.parse(fs.readFileSync(path, 'utf8'));
            const totalConfirmed = tests.reduce((s, t) => s + (t.summary?.confirmedIssues || 0), 0);
            const totalInfo = tests.reduce((s, t) => s + (t.summary?.infoIssues || 0), 0);
            if (totalConfirmed > 0 || totalInfo > 0) {
              const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: `**QueryAudit Report**: ${totalConfirmed} confirmed, ${totalInfo} info. See the [build artifacts](${runUrl}) for the per-test HTML reports.`
              });
            }

GitLab CI

Basic Setup with MySQL

test:
  image: eclipse-temurin:17-jdk
  services:
    - name: mysql:8.0
      alias: mysql
      variables:
        MYSQL_ROOT_PASSWORD: test
        MYSQL_DATABASE: testdb
  variables:
    SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/testdb"
    SPRING_DATASOURCE_USERNAME: root
    SPRING_DATASOURCE_PASSWORD: test
  script:
    - ./gradlew test
  artifacts:
    when: always
    paths:
      - build/reports/query-audit/
    expire_in: 7 days

With PostgreSQL

test:
  image: eclipse-temurin:17-jdk
  services:
    - name: postgres:16
      alias: postgres
      variables:
        POSTGRES_USER: test
        POSTGRES_PASSWORD: test
        POSTGRES_DB: testdb
  variables:
    SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/testdb"
    SPRING_DATASOURCE_USERNAME: test
    SPRING_DATASOURCE_PASSWORD: test
  script:
    - ./gradlew test
  artifacts:
    when: always
    paths:
      - build/reports/query-audit/
    expire_in: 7 days

With Baseline Update on Main

test:
  image: eclipse-temurin:17-jdk
  services:
    - name: mysql:8.0
      alias: mysql
      variables:
        MYSQL_ROOT_PASSWORD: test
        MYSQL_DATABASE: testdb
  variables:
    SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/testdb"
    SPRING_DATASOURCE_USERNAME: root
    SPRING_DATASOURCE_PASSWORD: test
  script:
    - ./gradlew test
  artifacts:
    when: always
    paths:
      - build/reports/query-audit/
    expire_in: 7 days

update-baseline:
  image: eclipse-temurin:17-jdk
  stage: deploy
  only:
    - main
  services:
    - name: mysql:8.0
      alias: mysql
      variables:
        MYSQL_ROOT_PASSWORD: test
        MYSQL_DATABASE: testdb
  variables:
    SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/testdb"
    SPRING_DATASOURCE_USERNAME: root
    SPRING_DATASOURCE_PASSWORD: test
  script:
    - ./gradlew test -DqueryAudit.updateBaseline=true
    - git config user.name "GitLab CI"
    - git config user.email "ci@example.com"
    - git add .query-audit-counts
    - git diff --cached --quiet || git commit -m "chore: update query-audit baseline"
    - git push

Jenkins

pipeline {
    agent any

    stages {
        stage('Test') {
            steps {
                sh './gradlew test'
            }
        }
    }

    post {
        always {
            archiveArtifacts artifacts: 'build/reports/query-audit/**', allowEmptyArchive: true
            junit 'build/test-results/test/*.xml'
        }
    }
}
node {
    stage('Checkout') {
        checkout scm
    }
    stage('Test') {
        try {
            sh './gradlew test'
        } finally {
            archiveArtifacts artifacts: 'build/reports/query-audit/**', allowEmptyArchive: true
            junit 'build/test-results/test/*.xml'
        }
    }
}

Maven Projects

All examples above use Gradle. For Maven projects, replace ./gradlew test with:

mvn test

And adjust artifact paths from build/reports/query-audit/ to target/reports/query-audit/.


For CI environments, use these application.yml settings:

query-audit:
  enabled: true
  fail-on-detection: true
  auto-open-report: false              # No browser in CI
  report:
    format: console
    output-dir: build/reports/query-audit
  suppress-queries:
    - "SELECT 1"                       # Health-check queries

Separate CI profile

Create a src/test/resources/application-ci.yml with CI-specific settings and activate it with SPRING_PROFILES_ACTIVE=ci in your CI environment.


Strategies for Gradual Adoption

If you are introducing QueryAudit to a large existing project, you may not want every pre-existing issue to break the build immediately. Here are some strategies:

1. Report-Only Mode First

Start with fail-on-detection: false to see what QueryAudit finds without failing any builds:

query-audit:
  fail-on-detection: false

Review the reports, fix what you can, and then switch to true.

2. Fail Only on Critical Issues

Use @QueryAudit(failOn = {...}) to limit which issue types cause failures:

@QueryAudit(failOn = {IssueType.N_PLUS_ONE, IssueType.MISSING_WHERE_INDEX})
@SpringBootTest
class OrderServiceTest { }

3. Suppress Known Issues

Suppress issues that are known and accepted, then enable fail-on-detection for everything else:

query-audit:
  fail-on-detection: true
  suppress-patterns:
    - "select-all"
    - "missing-where-index:legacy_table.old_column"

4. Use Baseline for Regression Detection

Establish a baseline of existing issues, then only fail on new regressions:

# First run: create the baseline
./gradlew test -DqueryAudit.updateBaseline=true

# Subsequent runs: detect regressions against baseline
./gradlew test

Commit the .query-audit-counts baseline file to your repository so all team members and CI use the same baseline.


See Also