From 47fb3fed4991e93615896aa1b9e1e75dfe4c48ea Mon Sep 17 00:00:00 2001 From: sshevchenko Date: Fri, 7 Aug 2026 20:30:05 +0200 Subject: [PATCH] Add reusable Scala CI workflow --- .github/workflows/ci.yml | 144 +++++++++++++++++++++++++++++++++++++++ README.md | 69 +++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..17c263c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: Test a Scala project + +on: + workflow_call: + inputs: + scala_versions: + description: 'JSON array of Scala versions to build against' + type: string + default: '["2.13.18", "3.3.8"]' + java_version: + description: 'JDK version' + type: string + default: '17' + java_distribution: + description: 'JDK distribution' + type: string + default: 'temurin' + test_task: + description: >- + sbt task that runs the tests. Defaults to `testFull` on sbt 2 and `test` on sbt 1, + detected from project/build.properties. + type: string + default: '' + coverage: + description: 'Collect test coverage and upload it to Coveralls' + type: boolean + default: true + version_policy_check: + description: 'Run versionPolicyCheck (binary compatibility). Requires sbt-version-policy.' + type: boolean + default: true + scalafmt_check: + description: 'Check formatting with scalafmt' + type: boolean + default: true + doc_check: + description: 'Check that scaladoc builds' + type: boolean + default: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + scala: ${{ fromJSON(inputs.scala_versions) }} + + steps: + - name: checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: coursier/cache-action@v8 + + - name: setup Java ${{ inputs.java_version }} + uses: actions/setup-java@v5 + with: + java-version: ${{ inputs.java_version }} + distribution: ${{ inputs.java_distribution }} + cache: 'sbt' + + - name: setup SBT + uses: sbt/setup-sbt@v1 + with: + # sbt 2's disk cache is restored across runs and is not keyed on scoverage's + # instrumentation. A cached compile would be reused without re-emitting coverage data, + # leaving an empty report while the build still passes. + disk-cache: ${{ !inputs.coverage }} + + - name: resolve test task + id: tasks + env: + TEST_TASK: ${{ inputs.test_task }} + run: | + if [[ -n "$TEST_TASK" ]]; then + echo "test=$TEST_TASK" >> "$GITHUB_OUTPUT" + elif grep -qE '^sbt\.version\s*=\s*2\.' project/build.properties; then + # sbt 2 redefined `test` to run only tests that failed or never ran + echo "test=testFull" >> "$GITHUB_OUTPUT" + else + echo "test=test" >> "$GITHUB_OUTPUT" + fi + + # The coverage build runs before any other compile: scoverage's instrumentation is not part of + # sbt's compile cache key, so a plain compile done first would be reused here and the coverage + # report would come out empty. + - name: build ${{ matrix.scala }} + run: | + if [[ "${{ inputs.coverage }}" == "true" ]]; then + sbt "++${{ matrix.scala }}; clean; coverage; ${{ steps.tasks.outputs.test }}; coverageAggregate" + else + sbt "++${{ matrix.scala }}; clean; ${{ steps.tasks.outputs.test }}" + fi + + - name: locate coverage report + id: coverage + if: inputs.coverage && success() + run: echo "file=$(find . -path '*/coverage-report/cobertura.xml' | head -1)" >> "$GITHUB_OUTPUT" + + - name: fail if coverage report is empty + if: inputs.coverage && success() + env: + REPORT: ${{ steps.coverage.outputs.file }} + run: | + if [[ -z "$REPORT" || ! -f "$REPORT" ]]; then + echo "::error::no cobertura report was produced" + exit 1 + fi + # Only the root element's totals matter; individual classes may legitimately + # have no lines. + valid=$(grep -m1 -oE 'lines-valid="[0-9]+"' "$REPORT" | grep -oE '[0-9]+') + if [[ "${valid:-0}" -eq 0 ]]; then + echo "::error::coverage report is empty - instrumentation did not run" + exit 1 + fi + + - name: upload coverage + if: inputs.coverage && success() + uses: coverallsapp/github-action@v2 + with: + file: ${{ steps.coverage.outputs.file }} + format: cobertura + flag-name: Scala ${{ matrix.scala }} + + # These run as explicit tasks rather than through a project-local `check` alias, which can be + # stubbed out and then silently guarantees nothing. + - name: binary compatibility ${{ matrix.scala }} + if: inputs.version_policy_check + run: sbt "++${{ matrix.scala }}; versionPolicyCheck" + + - name: formatting ${{ matrix.scala }} + if: inputs.scalafmt_check + run: sbt "++${{ matrix.scala }}; scalafmtCheckAll; scalafmtSbtCheck" + + - name: scaladoc ${{ matrix.scala }} + if: inputs.doc_check + run: sbt "++${{ matrix.scala }}; Compile/doc" diff --git a/README.md b/README.md index f220f47..dadd2a6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,74 @@ # Scala GitHub Actions +## Scala CI workflow + +Runs tests, coverage, binary compatibility, formatting and scaladoc on every push and pull request. +Replaces the hand-written `ci.yml` that each project used to carry. + +### Setup + +Create `.github/workflows/ci.yml`: + +```yaml +name: CI + +on: + push: + branches: [ master ] + pull_request: + +jobs: + test: + uses: evolution-gaming/scala-github-actions/.github/workflows/ci.yml@v6 + secrets: inherit +``` + +Nothing else is required if the project uses the defaults below. Coverage is uploaded to Coveralls, +so `secrets: inherit` is needed for `GITHUB_TOKEN`. + +### Inputs + +| input | default | notes | +|---|---|---| +| `scala_versions` | `'["2.13.18", "3.3.8"]'` | JSON array; becomes the build matrix | +| `java_version` | `'17'` | | +| `java_distribution` | `'temurin'` | | +| `test_task` | auto | `testFull` on sbt 2, `test` on sbt 1, read from `project/build.properties` | +| `coverage` | `true` | collect coverage and upload to Coveralls | +| `version_policy_check` | `true` | requires [sbt-version-policy](https://github.com/scalacenter/sbt-version-policy/) | +| `scalafmt_check` | `true` | | +| `doc_check` | `true` | runs `Compile/doc` | + +Example for a project without `sbt-version-policy` and on a different Scala set: + +```yaml +jobs: + test: + uses: evolution-gaming/scala-github-actions/.github/workflows/ci.yml@v6 + secrets: inherit + with: + scala_versions: '["2.13.18", "3.3.7"]' + version_policy_check: false +``` + +### Why the steps are ordered this way + +Two sbt 2 behaviours make a naive coverage setup report nothing while still passing: + +* sbt 2's compile cache is **not** keyed on scoverage's instrumentation. If a plain compile runs + first, the coverage build reuses those uninstrumented classes and the report comes out empty. The + coverage build therefore runs **before** the formatting, binary-compatibility and scaladoc checks. +* `sbt/setup-sbt` restores `~/.cache/sbt` across runs by default, which reintroduces the same problem + on any run whose build files did not change. This workflow sets `disk-cache: false` whenever + coverage is enabled. + +The workflow also fails if the produced cobertura report has no valid lines, so a silently empty +report is an error rather than a green build. + +Binary compatibility, formatting and scaladoc run as **explicit sbt tasks**, not via a project-local +`check` alias. An alias can be stubbed out (`addCommandAlias("check", "show version")`), which makes +the gate silently guarantee nothing. + ## Scala Release workflow (v3, v4, v5) ### Setup