diff --git a/.github/workflows/api-diff.yml b/.github/workflows/api-diff.yml new file mode 100644 index 0000000..adab578 --- /dev/null +++ b/.github/workflows/api-diff.yml @@ -0,0 +1,102 @@ +name: API compatibility + +# Fails a pull request that breaks the HTTP API for existing clients. The OpenAPI document is generated by the +# backend's annotation processor (backend/build/classes/java/main/META-INF/swagger/quelea.yml), so the base and the +# head are each built from their own checkout and compared as files. + +on: + pull_request: + +permissions: + contents: read + +jobs: + api-diff: + name: OpenAPI breaking changes + runs-on: ubuntu-latest + env: + SPEC_PATH: backend/build/classes/java/main/META-INF/swagger/quelea.yml + ONELITEFEATHER_MAVEN_USERNAME: ${{ secrets.ONELITEFEATHER_MAVEN_USERNAME }} + ONELITEFEATHER_MAVEN_PASSWORD: ${{ secrets.ONELITEFEATHER_MAVEN_PASSWORD }} + steps: + - name: Checkout the PR base + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + + - name: Checkout the PR head + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: head + persist-credentials: false + + - name: Set up JDK 25 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "25" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build the OpenAPI document of the base + id: base + working-directory: base + run: | + ./gradlew :backend:compileJava + mkdir -p "${GITHUB_WORKSPACE}/api-diff" + if [ -f "${SPEC_PATH}" ]; then + cp "${SPEC_PATH}" "${GITHUB_WORKSPACE}/api-diff/base.yml" + echo "present=true" >> "${GITHUB_OUTPUT}" + else + echo "present=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Build the OpenAPI document of the head + if: steps.base.outputs.present == 'true' + working-directory: head + run: | + ./gradlew :backend:compileJava + cp "${SPEC_PATH}" "${GITHUB_WORKSPACE}/api-diff/head.yml" + + - name: Explain the skipped check + if: steps.base.outputs.present != 'true' + run: echo "::notice::The base has no quelea.yml, so there is no API to compare against. Skipping the check." + + - name: Summarize the API changes + if: steps.base.outputs.present == 'true' + uses: oasdiff/oasdiff-action/changelog@v0.1.21 + with: + base: api-diff/base.yml + revision: api-diff/head.yml + format: markdown + output-to-file: api-diff/changelog.md + # Keep the specs inside CI: no upload to oasdiff.com and no PR comment. + review: "false" + github-token: "" + + - name: Write the changelog to the job summary + if: steps.base.outputs.present == 'true' + run: | + { + echo "## API changelog" + echo + if [ -s api-diff/changelog.md ]; then + cat api-diff/changelog.md + else + echo "No API changes." + fi + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Fail on breaking changes + if: steps.base.outputs.present == 'true' + uses: oasdiff/oasdiff-action/breaking@v0.1.21 + with: + base: api-diff/base.yml + revision: api-diff/head.yml + fail-on: ERR + review: "false" + github-token: "" diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 35575a9..eea9c89 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -17,3 +17,50 @@ jobs: java-distribution: "temurin" run-tests: true secrets: inherit + + # The shared gradle-build-pr workflow has no coverage upload, so this job follows Ingot: a JaCoCo run of both + # test modules published to Codecov. It runs the suite a second time next to the shared build; the cost is + # accepted to keep the coverage independent of the reusable workflow. Codecov runs with fail_ci_if_error false + # and no threshold, so coverage never fails the build. + coverage: + name: Coverage + runs-on: ubuntu-latest + env: + ONELITEFEATHER_MAVEN_USERNAME: ${{ secrets.ONELITEFEATHER_MAVEN_USERNAME }} + ONELITEFEATHER_MAVEN_PASSWORD: ${{ secrets.ONELITEFEATHER_MAVEN_PASSWORD }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up JDK 25 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "25" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Run tests with coverage + run: ./gradlew :backend:test :velocity:test jacocoTestReport + + - name: Upload JaCoCo reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: jacoco-reports + path: | + backend/build/reports/jacoco/ + velocity/build/reports/jacoco/ + if-no-files-found: ignore + retention-days: 14 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2 + with: + files: backend/build/reports/jacoco/test/jacocoTestReport.xml,velocity/build/reports/jacoco/test/jacocoTestReport.xml + fail_ci_if_error: false + # Tokenless uploads are rate limited. Set CODECOV_TOKEN in the repository secrets. + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 36eb108..1d0d1a0 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,14 @@ Integration tests use Testcontainers and need a running Docker daemon. They are Specifications and the change history live in `openspec/`. +## CI + +Every pull request runs: + +- **Build:** the shared OneLiteFeather Gradle workflow (`build test`, JDK 25). +- **API compatibility:** `oasdiff` compares the OpenAPI document of the base with the head and fails on breaking changes. The changelog goes to the job summary (`.github/workflows/api-diff.yml`). +- **Coverage:** JaCoCo XML and HTML reports for `backend` and `velocity`, uploaded to Codecov and as a workflow artifact. Coverage has no threshold and never fails the build (`.github/workflows/build-pr.yml`). + ## License [AGPL-3.0](LICENSE) diff --git a/build.gradle.kts b/build.gradle.kts index ebdb792..e3ebec1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,10 +20,13 @@ subprojects { dependsOn(project.tasks.named("test")) reports { xml.required.set(true) + html.required.set(true) } } getByName("test") { useJUnitPlatform() + // Coverage reports follow every test run, so `./gradlew test` leaves them in build/reports/jacoco. + finalizedBy("jacocoTestReport") testLogging { events("passed", "skipped", "failed") }