From 8d70c4884ac5fa0660407b64dbc01444de81021c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 28 Aug 2026 16:48:51 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Add=20a=20binary-compatibility=20check=20(M?= =?UTF-8?q?iMa)=20=E2=80=94=20commons=20prototype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There's no MiMa plugin for scala-cli, so .mima/bin-compat-check.scala calls mima-core's API directly (Scala 2.13 script, same pattern as .scoverage/report.sc) to compare the last released JAR against a freshly built --library JAR, both directions. .github/workflows/mima.yml wires it in. Report-only for now: pre-1.0, and this is a prototype — incompatibilities surface as a warning + job summary, they don't fail the build. Standalone workflow (not synced from halotukozak-com/.github) until it's proven. TASTy-MiMa isn't included: tasty-mima 1.4.1 can't read Scala 3.9.0 TASTy yet ("TASTy signature has wrong version"). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019awuXm5QZgXajLw3PvGo1s --- .github/workflows/mima.yml | 58 ++++++++++++++++++++++++++++++++++++ .mima/bin-compat-check.scala | 42 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 .github/workflows/mima.yml create mode 100644 .mima/bin-compat-check.scala diff --git a/.github/workflows/mima.yml b/.github/workflows/mima.yml new file mode 100644 index 0000000..19e974a --- /dev/null +++ b/.github/workflows/mima.yml @@ -0,0 +1,58 @@ +name: Binary compatibility + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +# Report-only for now: pre-1.0, and the check itself is a prototype. Problems +# are surfaced as a warning + job summary, they don't fail the build. +jobs: + mima: + name: MiMa (binary) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup coursier cache + uses: coursier/cache-action@v8.1 + - uses: VirtusLab/scala-cli-setup@v1 + - name: Check binary compatibility against the last release + run: | + set -euo pipefail + + base_tag=$(git tag -l 'v*' --sort=-v:refname | head -1) + if [ -z "$base_tag" ]; then + echo "No release tag yet — nothing to compare against." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + base_version=${base_tag#v} + org=$(grep -oP '(?<=using publish.organization ).*' project.scala | tr -d '"') + name=$(grep -oP '(?<=using publish.name ).*' project.scala | tr -d '"') + echo "Baseline: $org:${name}_3:$base_version" + + scala-cli --power package . --library -o "$RUNNER_TEMP/new.jar" --force + + printf '' > "$RUNNER_TEMP/empty.scala" + old_cp=$(scala-cli --power compile "$RUNNER_TEMP/empty.scala" --dependency "$org::$name:$base_version" --print-class-path | tail -1) + old_jar=$(printf '%s' "$old_cp" | tr ':' '\n' | grep "/${name}_3/$base_version/") + shared_cp=$(scala-cli --power compile . --print-class-path | tail -1) + + set +e + scala-cli run .mima/bin-compat-check.scala -- "$old_jar" "$RUNNER_TEMP/new.jar" "$shared_cp" | tee "$RUNNER_TEMP/mima-out.txt" + status=$? + set -e + + if [ $status -ne 0 ]; then + { + echo "### MiMa found binary incompatibilities vs \`$base_version\`" + echo '```' + cat "$RUNNER_TEMP/mima-out.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=Binary compatibility::MiMa found incompatibilities vs $base_version — see the job summary" + else + echo "Binary compatible with \`$base_version\`." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.mima/bin-compat-check.scala b/.mima/bin-compat-check.scala new file mode 100644 index 0000000..10879b3 --- /dev/null +++ b/.mima/bin-compat-check.scala @@ -0,0 +1,42 @@ +//> using scala 2.13 +//> using dep com.typesafe::mima-core:1.1.6 + +// Backward + forward binary-compatibility check between a released artifact and +// the current build, via MiMa's core API (there is no scala-cli MiMa plugin). +// +// Usage: scala-cli run .mima/bin-compat-check.scala -- +// oldJar the previously released library JAR +// newJar the freshly built library JAR (scala-cli package --library) +// sharedClasspath pathSeparator-joined dependency classpath (scala3-library, deps, …) +// +// Exit code: 0 if compatible, 1 if any problems were found. + +import java.io.File +import com.typesafe.tools.mima.lib.MiMaLib +import com.typesafe.tools.mima.core.Problem + +object BinCompatCheck { + def main(args: Array[String]): Unit = { + val Array(oldJar, newJar, sharedCp) = args + val classpath = sharedCp.split(File.pathSeparator).iterator + .filter(_.nonEmpty).map(new File(_)).toList + + def problems(prev: String, curr: String): List[Problem] = + new MiMaLib(classpath).collectProblems(new File(prev), new File(curr), Nil) + + val backward = problems(oldJar, newJar) + val forward = problems(newJar, oldJar) + + def report(label: String, ps: List[Problem]): Unit = + if (ps.isEmpty) println(s"[mima] $label: OK") + else { + println(s"[mima] $label: ${ps.size} problem(s)") + ps.foreach(p => println(s" - ${p.description("current")}")) + } + + report("backward (code built against the release vs the new JAR)", backward) + report("forward (code built against the new JAR vs the release)", forward) + + if (backward.nonEmpty || forward.nonEmpty) sys.exit(1) + } +} From 41fa6fbd0cbd4da4ee918ea32daf098f75c89549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 28 Aug 2026 19:43:11 +0200 Subject: [PATCH 2/4] mima.yml: label PRs with needs-major on binary incompatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On pull_request runs, add the needs-major label when MiMa finds a binary break (creating the label if it doesn't exist yet), and remove it again on a later push once the PR is compatible again. Still non-blocking — this is the "small" reporting mechanism instead of failing the build. No needs-minor yet (would signal a source/TASTy-only break): TASTy-MiMa can't read Scala 3.9's TASTy format yet, so there's no signal to drive it from. Add it once tasty-mima catches up. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019awuXm5QZgXajLw3PvGo1s --- .github/workflows/mima.yml | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mima.yml b/.github/workflows/mima.yml index 19e974a..8a615ed 100644 --- a/.github/workflows/mima.yml +++ b/.github/workflows/mima.yml @@ -6,8 +6,19 @@ on: pull_request: branches: [ main ] -# Report-only for now: pre-1.0, and the check itself is a prototype. Problems -# are surfaced as a warning + job summary, they don't fail the build. +permissions: + contents: read + pull-requests: write + +# Report-only: pre-1.0, and the check itself is a prototype. It never fails +# the build — instead, on a PR, it manages a `needs-major` label (binary +# incompatibility means an eventual major bump) so the break is visible and +# trackable without blocking. The label is removed again once a later push +# to the same PR is compatible. +# +# There's no `needs-minor` (source/TASTy-compat-only breaks) yet: TASTy-MiMa +# can't read Scala 3.9's TASTy format yet (tasty-mima lags the compiler). +# Add it here once that's unblocked. jobs: mima: name: MiMa (binary) @@ -20,12 +31,14 @@ jobs: uses: coursier/cache-action@v8.1 - uses: VirtusLab/scala-cli-setup@v1 - name: Check binary compatibility against the last release + id: mima run: | set -euo pipefail base_tag=$(git tag -l 'v*' --sort=-v:refname | head -1) if [ -z "$base_tag" ]; then echo "No release tag yet — nothing to compare against." >> "$GITHUB_STEP_SUMMARY" + echo "broken=false" >> "$GITHUB_OUTPUT" exit 0 fi base_version=${base_tag#v} @@ -53,6 +66,25 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" echo "::warning title=Binary compatibility::MiMa found incompatibilities vs $base_version — see the job summary" + echo "broken=true" >> "$GITHUB_OUTPUT" else echo "Binary compatible with \`$base_version\`." >> "$GITHUB_STEP_SUMMARY" + echo "broken=false" >> "$GITHUB_OUTPUT" + fi + + - name: Label needs-major on binary incompatibility + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ "${{ steps.mima.outputs.broken }}" = "true" ]; then + gh label create needs-major --repo "${{ github.repository }}" \ + --color B60205 --description "Binary-incompatible change — needs a major version bump" \ + --force >/dev/null + gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" \ + --add-label needs-major + else + gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" \ + --remove-label needs-major || true fi From 00aa29c5e5dd6874f6c2fdbdcf734555b781f151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 28 Aug 2026 19:50:08 +0200 Subject: [PATCH 3/4] Gate needs-major on backward compat only; test with a real break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward compatibility (new API vs the old release) always shows "problems" whenever anything is added — that's expected for any minor bump, not a signal of anything wrong. Only backward (can code compiled against the release still link against the new build) is the actual SemVer contract a same-major release makes, so only that should gate needs-major. Forward is still printed for context. Also: temporarily rename wontHappen -> wontHappenTmpRenameForMimaTest to verify the needs-major label actually gets applied on a real backward break (will revert once confirmed). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019awuXm5QZgXajLw3PvGo1s --- .mima/bin-compat-check.scala | 16 +++++++++++----- commons/debugUtils.scala | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.mima/bin-compat-check.scala b/.mima/bin-compat-check.scala index 10879b3..94b82f4 100644 --- a/.mima/bin-compat-check.scala +++ b/.mima/bin-compat-check.scala @@ -1,15 +1,21 @@ //> using scala 2.13 //> using dep com.typesafe::mima-core:1.1.6 -// Backward + forward binary-compatibility check between a released artifact and -// the current build, via MiMa's core API (there is no scala-cli MiMa plugin). +// Binary-compatibility check between a released artifact and the current +// build, via MiMa's core API (there is no scala-cli MiMa plugin). // // Usage: scala-cli run .mima/bin-compat-check.scala -- // oldJar the previously released library JAR // newJar the freshly built library JAR (scala-cli package --library) // sharedClasspath pathSeparator-joined dependency classpath (scala3-library, deps, …) // -// Exit code: 0 if compatible, 1 if any problems were found. +// Only *backward* compatibility (can code compiled against oldJar still link +// against newJar) gates the exit code — that's the actual SemVer contract a +// same-major release makes. *Forward* is printed for context (it's what's new +// since oldJar) but always has "problems" whenever you add API, so it's never +// a reason to fail on its own. +// +// Exit code: 0 if backward-compatible, 1 otherwise. import java.io.File import com.typesafe.tools.mima.lib.MiMaLib @@ -35,8 +41,8 @@ object BinCompatCheck { } report("backward (code built against the release vs the new JAR)", backward) - report("forward (code built against the new JAR vs the release)", forward) + report("forward (new API vs the release — expected to list additions)", forward) - if (backward.nonEmpty || forward.nonEmpty) sys.exit(1) + if (backward.nonEmpty) sys.exit(1) } } diff --git a/commons/debugUtils.scala b/commons/debugUtils.scala index bf450e9..9fe1ac7 100644 --- a/commons/debugUtils.scala +++ b/commons/debugUtils.scala @@ -190,7 +190,7 @@ private def showTypeReprImpl[T: Type](using Quotes): Expr[Nothing] = import quotes.reflect.* typeReprInfo(TypeRepr.of[T]).dbg -private[halotukozak] def wontHappen(using Quotes, Position) = +private[halotukozak] def wontHappenTmpRenameForMimaTest(using Quotes, Position) = s"This code should never be executed".dbg // $COVERAGE-ON$ From 1970cbba4df821763fc7b754f26d5773bbceba3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 28 Aug 2026 19:52:35 +0200 Subject: [PATCH 4/4] Revert the temporary MiMa test break Confirmed: needs-major got applied on the backward-incompatible rename. Reverting wontHappen back to its real name now that the label mechanism is verified in both directions. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019awuXm5QZgXajLw3PvGo1s --- commons/debugUtils.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commons/debugUtils.scala b/commons/debugUtils.scala index 9fe1ac7..bf450e9 100644 --- a/commons/debugUtils.scala +++ b/commons/debugUtils.scala @@ -190,7 +190,7 @@ private def showTypeReprImpl[T: Type](using Quotes): Expr[Nothing] = import quotes.reflect.* typeReprInfo(TypeRepr.of[T]).dbg -private[halotukozak] def wontHappenTmpRenameForMimaTest(using Quotes, Position) = +private[halotukozak] def wontHappen(using Quotes, Position) = s"This code should never be executed".dbg // $COVERAGE-ON$