name: pr-adapters # Stage 1, the adapter half: the lanes a pull request must clear that `ci-quality-gates.yml` cannot # reach. # # It replaces web-pr.yml, websocket-pr.yml, httpclient-contract.yml and jpa-pr.yml, which were four # files split by module rather than by stage. Splitting by module is what made the duplication # invisible: each file opened with its own "unit and architecture" job running # `::test verifyCleanArchitectureDependencies`, and all four of those were already inside the # root `check` that ci-quality-gates.yml runs on every pull request with no path filter. Four jobs, # four runners, four Gradle configurations, zero additional coverage. They are gone; what is left # here is only what `check` does not run. # # What `check` does not run, and therefore what this file is for: # * lanes with their own source set — a second servlet container, a real Nginx, Reactor Netty; # * lanes selected by a tag that `test` excludes — the cross-stack parity recording comparison; # * lanes parameterised per run — one PostgreSQL major per job, one HTTP transport per job. # Each of those genuinely cannot run inside `check`, which is the test for whether a job belongs # here at all. # # Path filtering is per job rather than per workflow. The four files it replaces each carried an # `on.pull_request.paths` list, so the whole file was skipped or run as a unit; a change touching # web and JPA started two workflows and a change touching neither still started none. Here one # `changes` job computes the answer once from the pull request's own diff and every lane reads it. # The filter is a plain `git diff` rather than a filter action: this repository pins every action by # commit SHA and adding a third-party action to compute a boolean is a supply-chain decision, not a # convenience. on: pull_request: workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # One diff, read once. `workflow_dispatch` answers "everything changed", because a manual run is # somebody asking for the lanes and there is no base ref to compare against. changes: runs-on: ubuntu-latest timeout-minutes: 10 outputs: web: ${{ steps.filter.outputs.web }} websocket: ${{ steps.filter.outputs.websocket }} httpclient: ${{ steps.filter.outputs.httpclient }} jpa: ${{ steps.filter.outputs.jpa }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 with: # Both endpoints of the pull request's diff have to be present locally; the default # shallow fetch has neither the base commit nor the merge base. fetch-depth: 0 - name: Decide which adapter lanes this diff can affect id: filter env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail if [ "${GITHUB_EVENT_NAME}" != 'pull_request' ]; then changed='ALL' else if [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then echo "::error::pull request diff endpoints are missing; refusing to report no lanes" exit 1 fi changed="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")" fi # Fail closed rather than reporting "nothing changed": an empty diff on a pull request # means the comparison did not work, and a filter that answers false on a broken # comparison silently turns every lane below off. if [ "${changed}" != 'ALL' ] && [ -z "${changed}" ]; then echo "::error::the pull request diff is empty; the comparison did not run" exit 1 fi printf 'changed files:\n%s\n' "${changed}" emit() { lane="$1" shift if [ "${changed}" = 'ALL' ]; then printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}" printf 'lane %s: true (manual run)\n' "${lane}" return 0 fi for pattern in "$@"; do if printf '%s\n' "${changed}" | grep -qE -- "${pattern}"; then printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}" printf 'lane %s: true (%s)\n' "${lane}" "${pattern}" return 0 fi done printf '%s=false\n' "${lane}" >> "${GITHUB_OUTPUT}" printf 'lane %s: false\n' "${lane}" } # This workflow and the composite action every lane below uses are in every lane's path # set: a change to either changes what the lanes do, and a gate that does not re-run when # its own definition changes is a gate nobody has seen run in its current form. common='^\.github/workflows/pr-adapters\.yml$|^\.github/actions/' emit web \ '^src/adapter/inbound/web/' \ '^src/application-core/src/.*/operation/' \ '^src/application-core/src/.*/idempotency/' \ '^src/adapter/outbound/persistence-jpa/src/.*/operation/' \ '^docs/web/' \ "${common}" emit websocket \ '^src/adapter/inbound/websocket/' \ '^docs/websocket/' \ "${common}" emit httpclient \ '^src/adapter/outbound/httpclient/' \ '^src/app-bootstrap/src/.*/httpclient/' \ '^docs/httpclient/' \ '^scripts/verify-httpclient-docs\.py$' \ "${common}" emit jpa \ '^src/adapter/outbound/persistence-jpa/' \ '^src/app-bootstrap/src/.*/jpa/' \ '^src/config/architecture/modules\.json$' \ '^docs/jpa/' \ '^docs/adr/ADR-JPA-' \ '^infra/jpa/' \ "${common}" # The parity gate depends on all three recording lanes and fails when one is missing, so it runs # them itself rather than trusting a previous job to have left the recordings behind. Its tag is # excluded from `test`, which is why `check` cannot cover it. web-cross-stack-parity: needs: changes if: needs.changes.outputs.web == 'true' runs-on: ubuntu-latest timeout-minutes: 40 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Compare the wire contract across Tomcat, Jetty and Reactor Netty working-directory: src run: >- ./gradlew :adapter:inbound:web:webCrossStackParityTest --no-daemon --stacktrace - name: Publish the parity recordings if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 with: name: web-contract-parity path: src/adapter/inbound/web/build/web-contract-parity/ if-no-files-found: error # Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy # contract that quietly passes without a proxy has been certifying nothing since whenever the # container runtime last broke. web-nginx-proxy-contract: needs: changes if: needs.changes.outputs.web == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Run the proxy, prefix and spoofing contract behind a real Nginx working-directory: src run: >- ./gradlew :adapter:inbound:web:webNginxProxyTest --no-daemon --stacktrace websocket-container-matrix: needs: changes if: needs.changes.outputs.websocket == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Run the runtime contract on the second servlet container working-directory: src run: >- ./gradlew :adapter:inbound:websocket:websocketJettyTest --no-daemon --stacktrace # Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most # common WebSocket deployment failure and it is invisible from either side alone. websocket-nginx-contract: needs: changes if: needs.changes.outputs.websocket == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Run the upgrade and forwarded-header contract behind a real Nginx working-directory: src run: >- ./gradlew :adapter:inbound:websocket:websocketNginxTest --no-daemon --stacktrace # One transport per job, so a transport that stops satisfying the shared contract fails on its own # row instead of disappearing into an aggregate run. `check` runs this lane once, unparameterised. httpclient-stable-contract: needs: changes if: needs.changes.outputs.httpclient == 'true' runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false matrix: transport: [apache, jdk, reactor] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Certify one transport against the shared contract working-directory: src run: >- ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest -Phttpclient.contract.transports=${{ matrix.transport }} --no-daemon --stacktrace # Only the Spring 7.0 lane. httpClientSecurityTest, httpClientBlockHoundTest and # spring62ApiSurfaceScan used to run here too; all three are `dependsOn` of this leaf's `check` # (src/adapter/outbound/httpclient/build.gradle), so ci-quality-gates.yml already ran them on the # same pull request. spring70CompatibilityTest is deliberately outside `check` and is what is left. httpclient-security-and-compatibility: needs: changes if: needs.changes.outputs.httpclient == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Run the next-major Spring compatibility lane working-directory: src run: >- ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace # 16 and 18 — the ends of the Stable matrix. 17 runs in the integration stage. What this does not # do is skip the container lane on a runner without Docker: PostgreSqlContainerFactory throws, # because a skipped contract reports success for a database nobody tested. jpa-postgresql-contract: needs: changes if: needs.changes.outputs.jpa == 'true' runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false matrix: postgresql: ["16", "18"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }} working-directory: src run: >- ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest -Pjpa.matrix.versions=${{ matrix.postgresql }} --no-daemon --stacktrace jpa-migration-smoke: needs: changes if: needs.changes.outputs.jpa == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Run the migration upgrade smoke scenarios working-directory: src run: >- ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest --no-daemon --stacktrace