diff --git a/.github/workflows/mima.yml b/.github/workflows/mima.yml new file mode 100644 index 00000000..9ba96cbd --- /dev/null +++ b/.github/workflows/mima.yml @@ -0,0 +1,91 @@ +name: Binary compatibility + +on: + push: + branches: [ main ] + pull_request: + +permissions: + contents: read + pull-requests: write + +# Report-only, mirrors the MiMa setup on halotukozak-com/commons (synced +# from halotukozak-com/.github to the scala-cli repos) adapted for Mill. +# Never fails the build — instead, on a PR, it manages a `needs-major` label +# when there's a real *backward* binary incompatibility, and removes it +# again once a later push is compatible. +jobs: + mima: + name: MiMa (binary) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Set up JDK + uses: actions/setup-java@v6 + with: + java-version: 21 + distribution: 'temurin' + - name: Setup coursier cache + 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} + org=com.halotukozak + name=alpaca + echo "Baseline: $org:${name}_3:$base_version" + + new_jar=$(./mill --disable-ticker show jvm.jar | tail -1 | sed -E 's/^"[a-z]*ref:v[0-9]+:[0-9a-f]+://; s/"$//') + + 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/") + mill_cp=$(./mill --disable-ticker show jvm.compileClasspath | jq -r '.[]' | sed -E 's/^[a-z]*ref:v[0-9]+:[0-9a-f]+://') + shared_cp="$old_cp:$mill_cp" + + set +e + scala-cli run .mima/bin-compat-check.scala -- "$old_jar" "$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" + 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 diff --git a/.gitignore b/.gitignore index ffc375a0..25ed4278 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ benchmarks/outputs/ __pycache__/ .planning/ + +.scala-build/ diff --git a/.mima/bin-compat-check.scala b/.mima/bin-compat-check.scala new file mode 100644 index 00000000..94b82f4b --- /dev/null +++ b/.mima/bin-compat-check.scala @@ -0,0 +1,48 @@ +//> using scala 2.13 +//> using dep com.typesafe::mima-core:1.1.6 + +// 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, …) +// +// 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 +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 (new API vs the release — expected to list additions)", forward) + + if (backward.nonEmpty) sys.exit(1) + } +}