diff --git a/.github/workflows/mima.yml b/.github/workflows/mima.yml
index 6297041..134357c 100644
--- a/.github/workflows/mima.yml
+++ b/.github/workflows/mima.yml
@@ -10,22 +10,25 @@ 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.
+# Report-only: on a PR, labels `needs-major` (binary break) or `needs-minor`
+# (TASTy-only break), removed once compatible. Fails only if a check can't run.
+#
+# Two checks run against the same baseline:
+# - MiMa (.mima/bin-compat-check.scala): do classfiles compiled against the
+# baseline still link against the new JAR?
+# - TASTy-MiMa (.mima/tasty-compat-check.scala): does TASTy compiled against
+# the baseline still re-typecheck against the new JAR? That is what breaks
+# when a user's code inlines an `inline def` / macro of ours, which MiMa
+# can't see. Skips packages listed in `.mima/tasty-private-packages`.
#
# On a PR, the baseline is the current `main` tip (the merge commit's first
# parent), not the last release — otherwise a break introduced by PR n stays
# baked into `main` and every unrelated PR n+1, n+2, ... would keep getting
# relabeled until the next release, even though they changed nothing binary.
# On a plain push (e.g. a merge to main), there's no PR to label, so the
-# report falls back to comparing against the last release for the summary.
-#
-# 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.
+# report falls back to comparing against the last stable release (pre-release
+# tags such as v1.0.0-RC1 are skipped, unless there's no stable tag yet) for
+# the summary.
jobs:
mima:
name: MiMa (binary)
@@ -37,8 +40,10 @@ jobs:
- name: Setup coursier cache
uses: coursier/cache-action@v8.1
- uses: VirtusLab/scala-cli-setup@v1
- - name: Check binary compatibility
+ - name: Check binary and TASTy compatibility
id: mima
+ env:
+ EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
@@ -48,18 +53,28 @@ jobs:
scala-cli --power package . --exclude benchmark --library -o "$RUNNER_TEMP/new.jar" --force
shared_cp=$(scala-cli --power compile . --exclude benchmark --print-class-path | tail -1)
- if [ "${{ github.event_name }}" = "pull_request" ]; then
+ if [ "$EVENT_NAME" = "pull_request" ]; then
base_sha=$(git rev-parse HEAD^1)
baseline_label="main@${base_sha:0:7}"
- echo "Baseline: $baseline_label (does this PR itself introduce a new binary incompatibility?)"
+ echo "Baseline: $baseline_label (does this PR itself introduce a new incompatibility?)"
git worktree add --detach --quiet "$RUNNER_TEMP/old-src" "$base_sha"
scala-cli --power package "$RUNNER_TEMP/old-src" --exclude benchmark --library -o "$RUNNER_TEMP/old.jar" --force
old_jar="$RUNNER_TEMP/old.jar"
+ # Approximation, like MiMa's shared classpath: the old sources'
+ # dependencies are assumed to be the current ones.
+ old_cp="$shared_cp"
else
- base_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
+ # Latest stable release. `--sort=-v:refname` alone ranks v1.0.0-RC1
+ # *above* v1.0.0, so drop pre-release tags (anything with a `-` in
+ # it) and only fall back to the newest pre-release when there's no
+ # stable tag at all yet.
+ tags=$(git -c versionsort.suffix=- tag -l 'v*' --sort=-v:refname)
+ base_tag=$(printf '%s\n' "$tags" | grep -v -- '-' | head -1 || true)
+ if [ -z "$base_tag" ]; then
+ base_tag=$(printf '%s\n' "$tags" | head -1)
+ fi
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}
@@ -74,82 +89,148 @@ jobs:
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=$?
+ bin_status=${PIPESTATUS[0]}
+ # Drop scala-cli's own .scala-build output dirs from the TASTy classpaths:
+ # they hold the same classes as the JARs being compared.
+ old_tasty_cp=$(printf '%s' "$old_cp" | tr ':' '\n' | grep -v '/\.scala-build/' | paste -sd: - || true)
+ new_tasty_cp=$(printf '%s' "$shared_cp" | tr ':' '\n' | grep -v '/\.scala-build/' | paste -sd: - || true)
+ scala-cli run .mima/tasty-compat-check.scala -- "$old_jar" "$old_tasty_cp" "$RUNNER_TEMP/new.jar" "$new_tasty_cp" | tee "$RUNNER_TEMP/tasty-out.txt"
+ tasty_status=${PIPESTATUS[0]}
set -e
- if [ $status -ne 0 ]; then
- {
- echo "### MiMa found binary incompatibilities vs \`$baseline_label\`"
- echo '```'
- cat "$RUNNER_TEMP/mima-out.txt"
- echo '```'
- } >> "$GITHUB_STEP_SUMMARY"
- echo "::warning title=Binary compatibility::MiMa found incompatibilities vs $baseline_label — see the job summary"
- echo "broken=true" >> "$GITHUB_OUTPUT"
- else
- echo "Binary compatible with \`$baseline_label\`." >> "$GITHUB_STEP_SUMMARY"
- echo "broken=false" >> "$GITHUB_OUTPUT"
+ # 0 = compatible, 2 = incompatible, else the check couldn't run: fail
+ # here so the label/comment steps are skipped.
+ tool_errors=0
+ for check in "MiMa (bin-compat-check.scala):$bin_status" "TASTy-MiMa (tasty-compat-check.scala):$tasty_status"; do
+ status=${check##*:}
+ if [ "$status" != 0 ] && [ "$status" != 2 ]; then
+ echo "::error title=Binary compatibility::${check%:*} could not run (exit code $status) — see the log above"
+ tool_errors=1
+ fi
+ done
+ if [ "$tool_errors" != 0 ]; then
+ echo "Compatibility check against \`$baseline_label\` could not run — see the job log." >> "$GITHUB_STEP_SUMMARY"
+ exit 1
+ fi
+
+ if [ "$bin_status" = 2 ]; then binary=broken; else binary=ok; fi
+ if [ "$tasty_status" = 2 ]; then tasty=broken; else tasty=ok; fi
+ if [ "$binary" = broken ]; then bump=major
+ elif [ "$tasty" = broken ]; then bump=minor
+ else bump=none
fi
- - name: Label needs-major on binary incompatibility
+ mark() { if [ "$1" = ok ]; then echo '✅ compatible'; else echo '❌ incompatible'; fi; }
+
+ # One report for both the job summary and the sticky PR comment.
+ {
+ echo "| Check vs \`$baseline_label\` | Result |"
+ echo '| --- | --- |'
+ echo "| Binary (MiMa) | $(mark "$binary") |"
+ echo "| TASTy (TASTy-MiMa) | $(mark "$tasty") |"
+ echo "| Implied version bump | **$bump** |"
+ echo
+ echo "A TASTy incompatibility means other libraries' \`inline\` code that uses this API may fail to re-typecheck when it gets inlined against the new version; that is neither binary nor source compatibility."
+ echo
+ echo 'Details
'
+ echo
+ echo '```'
+ echo "MiMa (binary):"
+ cat "$RUNNER_TEMP/mima-out.txt"
+ echo
+ echo "TASTy-MiMa (TASTy):"
+ cat "$RUNNER_TEMP/tasty-out.txt"
+ echo '```'
+ echo
+ echo ' '
+ } > "$RUNNER_TEMP/compat-report.md"
+
+ cat "$RUNNER_TEMP/compat-report.md" >> "$GITHUB_STEP_SUMMARY"
+ case "$bump" in
+ major) echo "::warning title=Binary compatibility::binary incompatibility vs $baseline_label (needs a major version bump) — see the job summary" ;;
+ minor) echo "::warning title=Binary compatibility::TASTy incompatibility vs $baseline_label (needs at least a minor version bump) — see the job summary" ;;
+ esac
+ {
+ echo "binary=$binary"
+ echo "tasty=$tasty"
+ echo "bump=$bump"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Label needs-major / needs-minor
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ BUMP: ${{ steps.mima.outputs.bump }}
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
- - name: Post sticky comment with binary compatibility report
+ # --force creates the label or updates its colour/description.
+ gh label create needs-major --repo "$REPO" --color B60205 --force \
+ --description "Binary-incompatible change — needs a major version bump" >/dev/null
+ gh label create needs-minor --repo "$REPO" --color FBCA04 --force \
+ --description "TASTy-incompatible (other libraries' inline code may fail to re-typecheck) — needs a minor bump" >/dev/null
+
+ case "$BUMP" in
+ major) wanted=needs-major ;;
+ minor) wanted=needs-minor ;;
+ *) wanted= ;;
+ esac
+
+ current=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
+ for label in needs-major needs-minor; do
+ if [ "$label" = "$wanted" ]; then
+ gh pr edit "$PR" --repo "$REPO" --add-label "$label"
+ elif printf '%s\n' "$current" | grep -qx "$label"; then
+ gh pr edit "$PR" --repo "$REPO" --remove-label "$label"
+ fi
+ done
+
+ - name: Post sticky comment with compatibility report
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ BASELINE: ${{ steps.mima.outputs.baseline }}
+ BUMP: ${{ steps.mima.outputs.bump }}
run: |
set -euo pipefail
+ # Nothing was compared (no baseline yet): nothing to report.
+ [ -f "$RUNNER_TEMP/compat-report.md" ] || exit 0
+
marker=''
- baseline='${{ steps.mima.outputs.baseline }}'
- repo='${{ github.repository }}'
- pr=${{ github.event.pull_request.number }}
- comment_id=$(gh api "repos/$repo/issues/$pr/comments" --paginate \
+ comment_id=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \
--jq ".[] | select(.body | startswith(\"$marker\")) | .id" | tail -1)
- if [ "${{ steps.mima.outputs.broken }}" = "true" ]; then
- {
- echo "$marker"
- echo "### ⚠️ MiMa: binary incompatibility vs \`$baseline\`"
- echo
- echo "This PR breaks binary compatibility — it will need a major version bump."
- echo
- echo 'Details
'
- echo
- echo '```'
- cat "$RUNNER_TEMP/mima-out.txt"
- echo '```'
- echo
- echo ' '
- } > "$RUNNER_TEMP/mima-comment.md"
-
- if [ -n "$comment_id" ]; then
- gh api --method PATCH "repos/$repo/issues/comments/$comment_id" -F body=@"$RUNNER_TEMP/mima-comment.md" >/dev/null
- else
- gh api --method POST "repos/$repo/issues/$pr/comments" -F body=@"$RUNNER_TEMP/mima-comment.md" >/dev/null
- fi
- elif [ -n "$comment_id" ]; then
- {
- echo "$marker"
- echo "### ✅ MiMa: binary compatible with \`$baseline\`"
- echo
- echo "A later push resolved the binary incompatibility flagged above."
- } > "$RUNNER_TEMP/mima-comment.md"
- gh api --method PATCH "repos/$repo/issues/comments/$comment_id" -F body=@"$RUNNER_TEMP/mima-comment.md" >/dev/null
+ case "$BUMP" in
+ major)
+ title="### ❌ MiMa: binary incompatibility vs \`$BASELINE\`"
+ lead="This PR breaks binary compatibility — it needs a major version bump." ;;
+ minor)
+ title="### ⚠️ MiMa: TASTy incompatibility vs \`$BASELINE\`"
+ lead="This PR breaks TASTy (but not binary) compatibility — it needs at least a minor version bump." ;;
+ *)
+ # Compatible and never flagged: stay quiet.
+ [ -n "$comment_id" ] || exit 0
+ title="### ✅ MiMa: binary and TASTy compatible with \`$BASELINE\`"
+ lead="A later push resolved the incompatibility flagged earlier." ;;
+ esac
+
+ {
+ echo "$marker"
+ echo "$title"
+ echo
+ echo "$lead"
+ echo
+ cat "$RUNNER_TEMP/compat-report.md"
+ } > "$RUNNER_TEMP/mima-comment.md"
+
+ if [ -n "$comment_id" ]; then
+ gh api --method PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@"$RUNNER_TEMP/mima-comment.md" >/dev/null
+ else
+ gh api --method POST "repos/$REPO/issues/$PR/comments" -F body=@"$RUNNER_TEMP/mima-comment.md" >/dev/null
fi
diff --git a/.mima/bin-compat-check.scala b/.mima/bin-compat-check.scala
index 187a92b..95b6442 100644
--- a/.mima/bin-compat-check.scala
+++ b/.mima/bin-compat-check.scala
@@ -15,7 +15,7 @@
// 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.
+// Exit code: 0 compatible, 2 incompatible, anything else = the check failed to run.
import java.io.File
import com.typesafe.tools.mima.lib.MiMaLib
@@ -24,6 +24,9 @@ import com.typesafe.tools.mima.core.Problem
object BinCompatCheck {
def main(args: Array[String]): Unit = {
val Array(oldJar, newJar, sharedCp) = args
+ // MiMa silently treats a missing JAR as empty.
+ for (jar <- List(oldJar, newJar))
+ require(new File(jar).isFile, s"not a file: $jar")
val classpath = sharedCp.split(File.pathSeparator).iterator
.filter(_.nonEmpty).map(new File(_)).toList
@@ -43,6 +46,6 @@ object BinCompatCheck {
report("backward (code built against the release vs the new JAR)", backward)
report("forward (new API vs the release — expected to list additions)", forward)
- if (backward.nonEmpty) sys.exit(1)
+ if (backward.nonEmpty) sys.exit(2)
}
}
diff --git a/.mima/tasty-compat-check.scala b/.mima/tasty-compat-check.scala
new file mode 100644
index 0000000..f64fde9
--- /dev/null
+++ b/.mima/tasty-compat-check.scala
@@ -0,0 +1,69 @@
+//> using scala 3.9.0
+//> using dep ch.epfl.scala::tasty-mima:1.4.1
+// tasty-mima 1.4.1 pulls tasty-query 1.6.1, which can't read Scala 3.8+/3.9
+// TASTy. tasty-query 1.9.0 can, and stays binary compatible within 1.x, so
+// pin it explicitly. Drop this once a tasty-mima release depends on >= 1.9.0.
+//> using dep ch.epfl.scala::tasty-query:1.9.0
+
+// TASTy-compatibility check between a released artifact and the current
+// build, via tasty-mima's core API (there is no scala-cli/Mill plugin; the
+// only build integration is sbt-tasty-mima).
+//
+// MiMa (bin-compat-check.scala) checks that already-linked classfiles still
+// link. This checks the other half of a Scala 3 library's contract: that
+// TASTy compiled against the old release — notably `inline` / macro bodies
+// that get re-typechecked when a user inlines them — still re-typechecks
+// against the new one.
+//
+// Usage: scala-cli run .mima/tasty-compat-check.scala --
+// oldJar the previously released library JAR
+// oldClasspath pathSeparator-joined dependency classpath of the old JAR
+// newJar the freshly built library JAR
+// newClasspath pathSeparator-joined dependency classpath of the new JAR
+// Either classpath may include its JAR or not; the JDK's java.base is added.
+//
+// Optional `.mima/tasty-private-packages` (one package per line, `#` comments): skipped by
+// tasty-mima. List only packages no downstream TASTy can reference (types, defaults, aliases).
+//
+// Exit code: 0 compatible, 2 incompatible, anything else = the check failed to run.
+
+import java.io.File
+import java.net.URI
+import java.nio.file.{Files, FileSystems, Path, Paths}
+
+import scala.jdk.CollectionConverters.*
+
+import tastymima.TastyMiMa
+import tastymima.intf.Config
+
+@main def tastyCompatCheck(oldJar: String, oldCp: String, newJar: String, newCp: String): Unit =
+ val javaBase = FileSystems.getFileSystem(URI.create("jrt:/")).getPath("modules", "java.base")
+
+ def classpath(jar: Path, cp: String): List[Path] =
+ val deps = cp.split(File.pathSeparator).iterator.filter(_.nonEmpty).map(Paths.get(_).toAbsolutePath).toList
+ (javaBase :: jar :: deps).distinct
+
+ val oldEntry = Paths.get(oldJar).toAbsolutePath
+ val newEntry = Paths.get(newJar).toAbsolutePath
+ for jar <- List(oldEntry, newEntry) do require(Files.isRegularFile(jar), s"not a file: $jar")
+
+ val privatePackagesFile = Paths.get(".mima/tasty-private-packages")
+ val privatePackages =
+ if !Files.exists(privatePackagesFile) then Nil
+ else
+ Files.readAllLines(privatePackagesFile).asScala.toList
+ .map(_.takeWhile(_ != '#').trim)
+ .filter(_.nonEmpty)
+ if privatePackages.nonEmpty then
+ println(s"[tasty-mima] treating as private (from $privatePackagesFile): ${privatePackages.mkString(", ")}")
+
+ val config = new Config().withMoreArtifactPrivatePackages(privatePackages.asJava)
+
+ val problems = new TastyMiMa(config)
+ .analyze(classpath(oldEntry, oldCp), oldEntry, classpath(newEntry, newCp), newEntry)
+
+ if problems.isEmpty then println("[tasty-mima] backward (TASTy built against the release vs the new JAR): OK")
+ else
+ println(s"[tasty-mima] backward (TASTy built against the release vs the new JAR): ${problems.size} problem(s)")
+ problems.foreach(p => println(s" - ${p.getDescription}"))
+ sys.exit(2)