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)