Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions .github/workflows/jewel-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,32 +100,100 @@ jobs:
run: ./scripts/validate-commit-message.sh

annotate_breaking_api_changes:
name: Check for breaking API changes with IJP dumps
name: Annotate breaking API changes with IJP dumps
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
# We need fetch-depth: '2' so we get HEAD~1 in case this is missing the PR token
fetch-depth: '2'
name: Check out repository

- name: Grant execute permission to the validation script
run: chmod +x ./scripts/validate-api-dump-changes.main.kts
run: chmod +x ./scripts/annotate-api-dump-changes.main.kts

- name: Annotate breaking API changes
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
run: ./scripts/validate-api-dump-changes.main.kts
run: ./scripts/annotate-api-dump-changes.main.kts

check_ij_api_dumps:
name: Check that the IJP API dumps are up-to-date
runs-on: ubuntu-latest
defaults:
run:
working-directory: .

steps:
- uses: actions/checkout@v4
name: Check out repository

- uses: actions/cache@v4
name: Cache for jps-bootstrap
with:
key: ${{ runner.os }}-jps-bootstrap-${{ hashFiles('platform/jps-bootstrap/**') }}
path: |
out
build/jps-bootstrap-work
build/download

- name: Grant execute permission to the validation script
run: chmod +x platform/jewel/scripts/check-api-dumps.main.kts

- name: Check that the IJP API dumps are up-to-date
run: ./scripts/check-api-dumps.main.kts
working-directory: platform/jewel

check_bazel_build:
name: Check that the Bazel build is up-to-date
runs-on: ubuntu-latest
defaults:
run:
working-directory: .

steps:
- uses: actions/checkout@v4
name: Check out repository

- uses: actions/checkout@v4
name: Checkout JetBrains/android submodule
with:
repository: JetBrains/android
path: android
ref: master

- name: Set up JBR 21
uses: actions/setup-java@v4
with:
java-version: 21
distribution: jetbrains
cache: gradle

- name: Grant execute permission to the Bazel build generator script
run: chmod +x build/jpsModelToBazelCommunityOnly.cmd

- name: Run the Bazel build generator script
run: ./build/jpsModelToBazelCommunityOnly.cmd

- name: Check that the Bazel build is up-to-date (except android submodule)
run: |
CHANGED_BAZEL_FILES=$(git diff --name-only HEAD | grep -E '\.(bzl|bazel)$|^BUILD|^WORKSPACE' | grep -v '^android/' || true)
if [ -n "$CHANGED_BAZEL_FILES" ]; then
echo "Error: Bazel files need to be updated:"
echo "$CHANGED_BAZEL_FILES"
echo "Run the jpsModelToBazelCommunityOnly.cmd script and commit the changes outside of the android submodule."
exit 1
fi
echo "Bazel build files look up-to-date."

metalava:
name: Check for breaking API changes with Metalava
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
fetch-depth: '1'
name: Check out repository

- name: Set up JBR 21
Expand Down
241 changes: 241 additions & 0 deletions platform/jewel/scripts/annotate-api-dump-changes.main.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
#!/usr/bin/env kotlin
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.

@file:Import("utils.main.kts")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
@file:DependsOn("com.github.ajalt.clikt:clikt-jvm:5.0.3")
@file:Suppress("RAW_RUN_BLOCKING")

import com.github.ajalt.clikt.command.SuspendingCliktCommand
import com.github.ajalt.clikt.command.main
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import java.io.File
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking

private class AnnotateApiDumpChangesCommand : SuspendingCliktCommand(name = "annotate") {
private val verbose: Boolean by option("--verbose", "-v", help = "Enable verbose output.").flag(default = false)

override fun help(context: Context): String = "Annotates API dump files for breaking changes against a base commit."

override suspend fun run() {
print("⏳ Locating Jewel root...")
val jewelRoot = findJewelRoot() ?: exitWithError("Could not find the Jewel root directory.")
println(" DONE: ${jewelRoot.canonicalPath}")

val baseCommit = determineBaseCommit(jewelRoot)
println("Checking against base commit: $baseCommit")

println("\nValidating stable API dumps...")
val stableViolations = validateDumps(experimental = false, baseCommit, jewelRoot) { it.name == "api-dump.txt" }

println("\nValidating experimental API dumps...")
val experimentalViolations =
validateDumps(experimental = true, baseCommit, jewelRoot) { it.name == "api-dump-experimental.txt" }

println("\nWriting summary...")
writeSummary(stableViolations, experimentalViolations)

println("\nDone processing API dumps")

if (stableViolations) {
exitWithError("Stable API breakages found.")
} else {
printlnSuccess("✅ No stable API breakages found.")
}
}

private suspend fun determineBaseCommit(jewelRoot: File): String =
if (checkPrNumber()) {
requireGhTool()
runCommand("gh pr view ${getPrNumber()} --json baseRefOid -q .baseRefOid", jewelRoot).getOrThrow().trim()
} else {
printlnWarn("GitHub PR number not found, falling back to checking against HEAD~1 instead")
runCommand("git rev-parse HEAD~1", jewelRoot).getOrThrow().trim()
}

private data class ValidationResult(val file: File, val log: CharSequence, val foundBreakages: Boolean)

private suspend fun validateDumps(
experimental: Boolean,
baseCommit: String,
jewelRoot: File,
dumpsFilter: (File) -> Boolean,
): Boolean {
val samplesDir: String = File(jewelRoot, "samples").absolutePath
val apiDumpFiles =
jewelRoot.walkTopDown().filter { dumpsFilter(it) && !it.absolutePath.startsWith(samplesDir) }.toList()

println()
println("Detected API dumps:\n${apiDumpFiles.joinToString("\n") { " * ${it.toRelativeString(jewelRoot)}" }}")

val results = coroutineScope {
apiDumpFiles
.map { file ->
async {
var foundBreakages = false
val log = buildString {
appendLine("\n Checking ${file.toRelativeString(jewelRoot)}")

val isModifiedResult =
runCommand(
"git diff --quiet $baseCommit -- ${file.absolutePath}",
jewelRoot,
exitOnError = false,
)

if (verbose) {
appendLine(" First diff result:\n${isModifiedResult.output}")
}

if (isModifiedResult.isFailure) {
appendLine(" Detected changes, investigating...")

val command = "git --no-pager diff --unified=0 $baseCommit -- ${file.absolutePath}"
if (verbose) {
appendLine(" Running: $command...")
}

val diffResult = runCommand(command, jewelRoot)
if (verbose) {
appendLine(" Second diff result:\n${diffResult.output}")
}

foundBreakages = processDiff(diffResult.output, file, experimental, this, jewelRoot)
}
}
ValidationResult(file, log, foundBreakages)
}
}
.awaitAll()
}

results.forEach { result -> print(result.log) }
return results.any { it.foundBreakages }
}

private val chunkHeaderRegex = "^@@ \\-([0-9]+)(?:,[0-9]+)? \\+([0-9]+)".toRegex()

private fun processDiff(
diff: String,
file: File,
experimental: Boolean,
log: StringBuilder,
jewelRoot: File,
): Boolean {
var foundBreakages = false
var oldLineNum = 0
var newLineNum = 0
var lastLineWasRemoval = false

diff.lines().forEach { line ->
when {
line.startsWith("@@") -> {
val match = chunkHeaderRegex.find(line)
if (match != null) {
oldLineNum = match.groupValues[1].toInt()
newLineNum = match.groupValues[2].toInt()
}
lastLineWasRemoval = false
}
line.startsWith("-") && !line.startsWith("---") -> {
reportBreakage(
file,
line,
log,
oldLineNum,
newLineNum,
experimental,
annotate = !lastLineWasRemoval,
jewelRoot,
)
foundBreakages = true
oldLineNum++
lastLineWasRemoval = true
}
line.startsWith("+") && !line.startsWith("+++") -> {
newLineNum++
lastLineWasRemoval = false
}
line.startsWith(" ") -> {
oldLineNum++
newLineNum++
lastLineWasRemoval = false
}
}
}

return foundBreakages
}

private fun reportBreakage(
file: File,
line: String,
log: StringBuilder,
oldLineNum: Int,
newLineNum: Int,
experimental: Boolean,
annotate: Boolean,
jewelRoot: File,
) {
val lineContent =
line
.substring(1) // Skip the first character (either + or -)
.replace("%", "%25") // Escape the % character
.replace("\r", "%0D") // Escape the \r character
.replace("\n", "%0A") // Escape the \n character

val type = if (experimental) "experimental" else "stable"
val message = "⚠️ Breaking $type API change:\n line $oldLineNum removed: $lineContent"
log.appendLine(" " + if (experimental) message.asWarning() else message.asError())

if (!annotate) return

// Use the magic log format that GitHub workflows accept to annotate code
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
val severity = if (experimental) "warning" else "error"
log.append("::$severity ")

val repoRoot = jewelRoot.parentFile.parentFile
log.append("file=${file.toRelativeString(repoRoot)},")

log.append("line=$newLineNum,")

val title = if (experimental) "Breaking experimental API change" else "Breaking API change"
log.appendLine("title=$title::This looks like a breaking API change, make sure it's intended.")
}

private fun writeSummary(stableViolations: Boolean, experimentalViolations: Boolean) {
val summary = buildString {
appendLine("## Binary check result")
if (!stableViolations && !experimentalViolations) {
appendLine("✅ No API breakages found.")
} else {
if (stableViolations) {
appendLine("❌ Stable API breakages found.")
}
if (experimentalViolations) {
appendLine("⚠️ Experimental API breakages found.")
}
}
}

println(summary.prependIndent())

// Write the summary to the magical GITHUB_STEP_SUMMARY file
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
val summaryFile = System.getenv("GITHUB_STEP_SUMMARY")?.takeIf { !it.isBlank() }?.let { File(it) }
if (summaryFile != null) {
summaryFile.writeText(summary)
println("Summary written to ${summaryFile.absolutePath}")
} else {
printlnWarn("GITHUB_STEP_SUMMARY environment variable not set")
}
}
}

runBlocking { AnnotateApiDumpChangesCommand().main(args) }
Loading
Loading