diff --git a/.github/actions/setup-kotlin/action.yml b/.github/actions/setup-kotlin/action.yml new file mode 100644 index 000000000000..a2625157da29 --- /dev/null +++ b/.github/actions/setup-kotlin/action.yml @@ -0,0 +1,54 @@ +name: Set up Kotlin +description: > + Installs a pinned Kotlin compiler and exposes its bin directory. The download is cached per OS and version, so it is + only fetched on a cache miss (first run on a runner, or after a version bump) rather than on every run. + +inputs: + version: + description: The Kotlin version to install (e.g., 2.3.20). + required: true + +outputs: + bin-dir: + description: Absolute path to the installed Kotlin bin directory. + value: ${{ steps.expose.outputs.bin-dir }} + +runs: + using: composite + steps: + - name: Restore cached Kotlin ${{ inputs.version }} + id: cache + uses: actions/cache@v4 + with: + key: kotlin-${{ runner.os }}-${{ inputs.version }} + path: ${{ runner.temp }}/kotlin-${{ inputs.version }} + + - name: Install Kotlin ${{ inputs.version }} + shell: bash + run: | + KOTLIN_HOME="${RUNNER_TEMP}/kotlin-${{ inputs.version }}" + KOTLINC="${KOTLIN_HOME}/bin/kotlinc" + + # Trust the restored cache only if the compiler is present and reports the expected version. + kotlin_cached_install_is_valid() { + [ -x "${KOTLINC}" ] || return 1 + "${KOTLINC}" -version 2>&1 | grep -qF "kotlinc-jvm ${{ inputs.version }} " + } + + # Self-healing: a missing, partial/corrupt, or version-mismatched cache falls through to (re)download. + if kotlin_cached_install_is_valid; then + echo "Kotlin ${{ inputs.version }} already present (cache hit), skipping download." + exit 0 + fi + KOTLIN_ARCHIVE="${RUNNER_TEMP}/kotlin-compiler-${{ inputs.version }}.zip" + rm -rf "${KOTLIN_HOME}" "${RUNNER_TEMP}/kotlinc" + curl -fsSL --retry 3 --retry-all-errors \ + -o "${KOTLIN_ARCHIVE}" \ + "https://github.com/JetBrains/kotlin/releases/download/v${{ inputs.version }}/kotlin-compiler-${{ inputs.version }}.zip" + unzip -q "${KOTLIN_ARCHIVE}" -d "${RUNNER_TEMP}" + mv "${RUNNER_TEMP}/kotlinc" "${KOTLIN_HOME}" + + - name: Expose Kotlin bin directory + id: expose + shell: bash + run: echo "bin-dir=${RUNNER_TEMP}/kotlin-${{ inputs.version }}/bin" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/jewel-checks.yml b/.github/workflows/jewel-checks.yml index b2de376939ec..c3128355ca1d 100644 --- a/.github/workflows/jewel-checks.yml +++ b/.github/workflows/jewel-checks.yml @@ -9,6 +9,15 @@ defaults: run: working-directory: platform/jewel +# Kotlin used to run the .main.kts CI scripts (annotate/check API dumps, Metalava). It is pinned +# here so the scripts do not depend on whatever Kotlin the runner image happens to ship. +# +# When Jewel's build Kotlin is updated, bump KOTLIN_VERSION to match so CI and the build stay aligned. +# Keep it below 2.4.0 until the .main.kts cross-`@file:Import` regression is fixed (FIR crash +# "Expected FirResolvedTypeRef ... but was FirUserTypeRefImpl"); see JEWEL-1339. +env: + KOTLIN_VERSION: 2.3.20 + jobs: checks: name: CI code checks @@ -110,14 +119,17 @@ jobs: fetch-depth: '2' name: Check out repository - - name: Grant execute permission to the validation script - run: chmod +x ./scripts/annotate-api-dump-changes.main.kts + - name: Set up Kotlin ${{ env.KOTLIN_VERSION }} + id: kotlin + uses: ./.github/actions/setup-kotlin + with: + version: ${{ env.KOTLIN_VERSION }} - name: Annotate breaking API changes env: PR_NUMBER: ${{ github.event.pull_request.number }} GH_TOKEN: ${{ github.token }} - run: ./scripts/annotate-api-dump-changes.main.kts + run: ${{ steps.kotlin.outputs.bin-dir }}/kotlin ./scripts/annotate-api-dump-changes.main.kts check_ij_api_dumps: name: Check that the IJP API dumps are up-to-date @@ -139,11 +151,14 @@ jobs: 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: Set up Kotlin ${{ env.KOTLIN_VERSION }} + id: kotlin + uses: ./.github/actions/setup-kotlin + with: + version: ${{ env.KOTLIN_VERSION }} - name: Check that the IJP API dumps are up-to-date - run: ./scripts/check-api-dumps.main.kts + run: ${{ steps.kotlin.outputs.bin-dir }}/kotlin ./scripts/check-api-dumps.main.kts working-directory: platform/jewel check_bazel_build: @@ -214,8 +229,11 @@ jobs: distribution: jetbrains cache: gradle - - name: Grant execute permission to the Metalava script - run: chmod +x ./scripts/metalava-signatures.main.kts + - name: Set up Kotlin ${{ env.KOTLIN_VERSION }} + id: kotlin + uses: ./.github/actions/setup-kotlin + with: + version: ${{ env.KOTLIN_VERSION }} - name: Grant execute permission to gradlew run: chmod +x gradlew @@ -224,4 +242,4 @@ jobs: env: PR_NUMBER: ${{ github.event.pull_request.number }} GH_TOKEN: ${{ github.token }} - run: ./scripts/metalava-signatures.main.kts validate + run: ${{ steps.kotlin.outputs.bin-dir }}/kotlin ./scripts/metalava-signatures.main.kts validate diff --git a/platform/jewel/docs/api-compatibility.md b/platform/jewel/docs/api-compatibility.md index 7da862ae2f91..5e6321d02291 100644 --- a/platform/jewel/docs/api-compatibility.md +++ b/platform/jewel/docs/api-compatibility.md @@ -10,6 +10,20 @@ There are two API surfaces: The current release version is read from [`gradle.properties`](../gradle.properties) via `jewel.release.version`. By default, the Metalava scripts validate and update dumps for that version. +## CI and the pinned Kotlin version + +These validation and update scripts are `.main.kts` Kotlin scripts. The +[Jewel Checks workflow](../../../.github/workflows/jewel-checks.yml) runs them (API dump checks and Metalava) using a +Kotlin compiler pinned via its `KOTLIN_VERSION` env and installed by the +[`setup-kotlin`](../../../.github/actions/setup-kotlin/action.yml) action, so CI does not depend on whichever Kotlin the +runner image happens to ship. + +[`gradle/libs.versions.toml`](../gradle/libs.versions.toml) (`kotlin`) is the source of truth for the Kotlin version; +`KOTLIN_VERSION` in the workflow must match it. **When you upgrade Kotlin, bump both in the same change** so the build +and CI stay aligned. A `JewelBuildTest` test asserts the two values are equal, so CI fails fast if they drift. Keep the +pinned version below 2.4.0 until the `.main.kts` regression resolving helpers imported across a `@file:Import` boundary +is fixed (see JEWEL-1339); otherwise the API dump and Metalava jobs will fail. + ## Validate API dumps To validate all Jewel API dumps, run this from the Jewel root: diff --git a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/JewelBuildTest.kt b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/JewelBuildTest.kt index 221a82a43cf5..4a42182bd732 100644 --- a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/JewelBuildTest.kt +++ b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/JewelBuildTest.kt @@ -3,6 +3,8 @@ package org.jetbrains.jewel.foundation import java.io.File import java.util.Properties +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue import org.junit.Test private const val JEWEL_MARKER_FILE_NAME = "JEWEL_MARKER" @@ -29,6 +31,45 @@ internal class JewelBuildTest { } } + @Test + fun `KOTLIN_VERSION in the jewel-checks workflow should match the build Kotlin in libs versions toml`() { + val jewelHome = findJewelHomeDir() + + val libsVersionsToml = jewelHome.resolve("gradle/libs.versions.toml") + if (!libsVersionsToml.isFile) { + error("Cannot load the libs.versions.toml file from ${libsVersionsToml.absolutePath}") + } + + // The workflow lives at the repository root, outside the Jewel tree, so it is not present in + // every environment that runs these tests (e.g. the Bazel sandbox). Skip when it is missing. + val workflowFile = jewelHome.parentFile?.parentFile?.resolve(".github/workflows/jewel-checks.yml") + assumeTrue( + "Skipping: the jewel-checks workflow is not available in this environment " + + "(${workflowFile?.absolutePath}).", + workflowFile?.isFile == true, + ) + + // libs.versions.toml is the source of truth; the workflow must follow it. + val buildKotlin = readBuildKotlinVersion(libsVersionsToml) + check(buildKotlin.isNotBlank()) { + "Could not find a `kotlin = \"...\"` entry under [versions] in ${libsVersionsToml.absolutePath}" + } + + val ciKotlin = readWorkflowKotlinVersion(workflowFile!!) + check(ciKotlin.isNotBlank()) { "Could not find a `KOTLIN_VERSION:` env entry in ${workflowFile.absolutePath}" } + + val errorMessage = buildString { + appendLine("The Kotlin version is out of sync between the Jewel build and the Jewel Checks CI workflow!") + appendLine("Build Kotlin (source of truth): '$buildKotlin'") + appendLine("Workflow KOTLIN_VERSION: '$ciKotlin'") + appendLine("----------------------------------------------------------") + appendLine("To fix, set KOTLIN_VERSION in '.github/workflows/jewel-checks.yml' to '$buildKotlin'") + appendLine("so it matches the 'kotlin' version in 'platform/jewel/gradle/libs.versions.toml'.") + } + + assertEquals(errorMessage, buildKotlin, ciKotlin) + } + private fun findJewelHomeDir(): File { val initialFile = File(".").canonicalFile @@ -68,4 +109,16 @@ internal class JewelBuildTest { val properties = Properties().apply { file.inputStream().use { load(it) } } return properties.getProperty("jewel.release.version").orEmpty() } + + // Matches the `kotlin = ""` entry under [versions] (not kotlinpoet, kotlinx*, etc.). + private fun readBuildKotlinVersion(libsVersionsToml: File): String = + Regex("""(?m)^\s*kotlin\s*=\s*"([^"]+)"""").find(libsVersionsToml.readText())?.groupValues?.get(1).orEmpty() + + // Matches the `KOTLIN_VERSION: ` workflow env entry (with or without surrounding quotes). + private fun readWorkflowKotlinVersion(workflowFile: File): String = + Regex("""(?m)^\s*KOTLIN_VERSION:\s*"?([^"\s#]+)"?""") + .find(workflowFile.readText()) + ?.groupValues + ?.get(1) + .orEmpty() }