From 65c20497ab82f5aab068763cd464b72d0b49555e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mareks=20Ramp=C4=81ns?= <8796159+mr-git@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:08:48 +0300 Subject: [PATCH] split pipeline steps into separate jobs, which run concurrently --- .github/workflows/ci.yml | 201 +++++++++++++++++++++++----------- .github/workflows/release.yml | 3 +- README.md | 61 +++++------ 3 files changed, 167 insertions(+), 98 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ac931a..e432b5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,22 +27,6 @@ on: `clean` 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 sonar: description: >- Run a SonarQube Cloud scan. Requires a SONAR_TOKEN secret and an existing project on @@ -69,36 +53,25 @@ concurrency: cancel-in-progress: true jobs: - test: + scala-versions: 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 + - name: Define Scala versions + id: scala + run: echo 'versions=${{ inputs.scala_versions }}' >> "$GITHUB_OUTPUT" - - uses: coursier/cache-action@v8 + outputs: + version: ${{ steps.scala.outputs.versions }} - - name: setup Java ${{ inputs.java_version }} - uses: actions/setup-java@v5 - with: - java-version: ${{ inputs.java_version }} - distribution: ${{ inputs.java_distribution }} - cache: 'sbt' + sbt-tasks: + runs-on: ubuntu-latest - - name: setup SBT - uses: sbt/setup-sbt@v1 + steps: + - name: checkout + uses: actions/checkout@v7 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 }} + persist-credentials: false - name: resolve test task id: test-task @@ -128,24 +101,53 @@ jobs: echo "clean=clean" >> "$GITHUB_OUTPUT" fi + outputs: + test: ${{ steps.test-task.outputs.test }} + clean: ${{ steps.clean-task.outputs.clean }} + + test-coverage: + runs-on: ubuntu-latest + needs: [scala-versions, sbt-tasks] + strategy: + matrix: + scala: ${{ fromJSON(needs.scala-versions.outputs.version) }} + + steps: + - name: checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: restore cache + 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. Disable disk cache to force full coverage run + disk-cache: false + # 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 }}; ${{ steps.clean-task.outputs.clean }}; coverage; ${{ steps.test-task.outputs.test }}; coverageAggregate" - else - sbt "++${{ matrix.scala }}; ${{ steps.clean-task.outputs.clean }}; ${{ steps.test-task.outputs.test }}" - fi + sbt "++${{ matrix.scala }}; ${{ needs.sbt-tasks.outputs.clean }}; coverage; ${{ needs.sbt-tasks.outputs.test }}; coverageAggregate" - name: locate coverage report id: coverage - if: inputs.coverage && success() + if: 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() + if: success() env: REPORT: ${{ steps.coverage.outputs.file }} run: | @@ -162,27 +164,13 @@ jobs: fi - name: upload coverage - if: inputs.coverage && success() + if: 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" - # Scanned once, on the first Scala version only: SonarQube tracks one analysis per branch, so # running it per matrix leg would have the legs overwrite each other. - name: sonar settings @@ -201,7 +189,7 @@ jobs: - name: sonar scan if: inputs.sonar && matrix.scala == fromJSON(inputs.scala_versions)[0] - uses: SonarSource/sonarqube-scan-action@v8.2 + uses: SonarSource/sonarqube-scan-action@v8 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: @@ -210,3 +198,92 @@ jobs: -Dsonar.projectKey=${{ steps.sonar.outputs.key }} -Dsonar.scala.coverage.reportPaths=${{ steps.sonar.outputs.reports }} ${{ inputs.sonar_args }} + + binary-compatibility: + runs-on: ubuntu-latest + needs: [scala-versions, sbt-tasks] + strategy: + matrix: + scala: ${{ fromJSON(needs.scala-versions.outputs.version) }} + + steps: + - name: checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + # checkout whole history with all tags - required by `versionPolicyCheck` for version comparison + fetch-depth: 0 + + - name: restore cache + 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 + + - name: binary compatibility ${{ matrix.scala }} + run: sbt "++${{ matrix.scala }}; versionPolicyCheck" + + formatting: + runs-on: ubuntu-latest + needs: [scala-versions, sbt-tasks] + strategy: + matrix: + scala: ${{ fromJSON(needs.scala-versions.outputs.version) }} + + steps: + - name: checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: restore cache + 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 + + - name: formatting ${{ matrix.scala }} + run: sbt "++${{ matrix.scala }}; scalafmtCheckRepo" + + scaladoc: + runs-on: ubuntu-latest + needs: [scala-versions, sbt-tasks] + strategy: + matrix: + scala: ${{ fromJSON(needs.scala-versions.outputs.version) }} + + steps: + - name: checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: restore cache + 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 + + - name: scaladoc ${{ matrix.scala }} + run: sbt "++${{ matrix.scala }}; ${{ needs.sbt-tasks.outputs.clean }}; Compile/doc" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fcb591..dc1d71f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,8 @@ jobs: with: fetch-depth: 0 - - uses: coursier/cache-action@v8 + - name: restore cache + uses: coursier/cache-action@v8 - name: setup Java 17 uses: actions/setup-java@v5 diff --git a/README.md b/README.md index abbf295..a3658e7 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Scala GitHub Actions -## Scala CI workflow +## Scala Continuous Integration (CI) workflow -Runs tests, coverage, binary compatibility, formatting and Scaladoc on every push and pull request. -Replaces the handwritten `ci.yml` that each project used to carry. +Runs tests with coverage, binary compatibility, formatting and Scaladoc checks on every pull request and push on +"main" branch. Replaces the handwritten `ci.yml` that each project used to carry. ### Setup @@ -19,7 +19,7 @@ on: jobs: test: - uses: evolution-gaming/scala-github-actions/.github/workflows/ci.yml@ # v6.2.0 + uses: evolution-gaming/scala-github-actions/.github/workflows/ci.yml@ # v6.4.0 ``` Nothing else is required if the project uses the defaults below. @@ -48,10 +48,6 @@ alternatives and drops the security rating to C: | `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` | | `sonar` | `false` | run a SonarQube Cloud scan, see below | | `sonar_project_key` | `_` | | | `sonar_args` | `''` | extra `-D` arguments for the scanner | @@ -61,33 +57,28 @@ 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.2.0 + uses: evolution-gaming/scala-github-actions/.github/workflows/ci.yml@ # v6.4.0 with: scala_versions: '["2.13.18", "3.3.7"]' - version_policy_check: false ``` -### Why the steps are ordered this way +### jobs in CI pipeline -Two sbt 2 behaviours make a naive coverage setup report nothing while still passing: +All checks are run concurrently! Ideally, we must strive to keep them all green, but, it is allowed to merge PR, if +some checks are red, for example if code formatting is not introduced, yet. Such red checks must be treated as nudge +to improve the quality of code in repo! -* 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. - -The workflow checks out with `fetch-depth: 0`. Without tags sbt-dynver reports the version as -`0.0.0`, `versionPolicyCheck` then has no previous version to compare against, and the binary -compatibility check passes without checking anything. +* `test-coverage` - runs with disabled disk cache for SBT setup action (`disk-cache: false`) to make sure that + test coverage gets run with fully instrumented compilation. 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. + If project has `sonar` integration configured and + enabled, then `sonar scan` will get run after coverage reports are uploaded +* `binary-compatibility` - runs [sbt-version-policy](https://github.com/scalacenter/sbt-version-policy/)'s + `versionPolicyCheck` task on repo with full history (`fetch-depth: 0`) to make sure that plugin can find the tag + for previous version +* `formatting` - runs [scalafmt](https://scalameta.org/scalafmt/)'s `scalafmtCheckRepo` task (requires at + least version 2.6.2) +* `scaladoc` - calls `Compile/doc` task to make sure that Scaladocs compile ### SonarQube Cloud @@ -128,13 +119,13 @@ being analysed should have the app removed rather than left in that state. To use Scala Release workflow have to set up project: * add latest [sbt-dynver](https://github.com/sbt/sbt-dynver) plugin * add Evolution's artifactory plugin [sbt-artifactory-plugin](https://github.com/evolution-gaming/sbt-artifactory-plugin) -* defined command alias `check` which runs code quality checks, for example: [scalafmt](https://scalameta.org/scalafmt/) - and [scalafix](https://scalacenter.github.io/scalafix/), and binary compatibility check by - [sbt-version-policy](https://github.com/scalacenter/sbt-version-policy/): +* defined command alias `check` which runs code quality checks, for + example: [scalafmt](https://scalameta.org/scalafmt/) and binary compatibility check + by [sbt-version-policy](https://github.com/scalacenter/sbt-version-policy/): ```sbt - addCommandAlias("fmt", "all scalafmtAll scalafmtSbt; scalafixEnable; scalafixAll") // optional: for development - addCommandAlias("check", "all versionPolicyCheck Compile/doc scalafmtCheckAll scalafmtSbtCheck; scalafixEnable; scalafixAll --check") - addCommandAlias("build", "all compile test") // optional: for development + addCommandAlias("fmt", "all scalafmtRepo") // optional: for development + addCommandAlias("check", "all versionPolicyCheck Compile/doc scalafmtCheckRepo") + addCommandAlias("build", "+all compile testFull") // optional: for development ``` as very minimum "no-op" placeholder: ```sbt