[JEWEL-1339] Decouple .main.kts CI scripts from runner Kotlin by pinning Kotlin 2.3.20

The Jewel Checks workflow ran its .main.kts scripts via the `/usr/bin/env kotlin`
shebang, i.e. whatever Kotlin the GitHub runner image happened to ship. A runner
image refresh rolled that to Kotlin 2.4.0, which has a .main.kts regression
resolving top-level helpers/extensions imported across a `@file:Import` boundary
(FIR crash "Expected FirResolvedTypeRef with ConeKotlinType but was
FirUserTypeRefImpl"), breaking the API-dump and Metalava jobs. Runner image drift
was the trigger; the actual bug is in Kotlin 2.4.0's .main.kts import handling.

Make CI deterministic and independent of the runner toolchain: every job that runs
a .main.kts script (annotate API dumps, check API dumps, Metalava) now installs a
pinned Kotlin (2.3.20, matching Jewel's build) through a local composite action
(.github/actions/setup-kotlin) and invokes that binary directly, instead of relying
on the shebang/PATH. The action caches the compiler per OS and version, so it is
only downloaded on a cache miss rather than on every run.

Pinning below 2.4.0 makes the import bug unreachable, so the scripts themselves are
left unchanged. tools/kotlin.cmd was intentionally not used: it pins a different
Kotlin (2.3.10) and is a shared tool outside this workflow's control that could be
bumped to 2.4.0 and silently reintroduce the failure.

Signed-off-by: Nebojsa.Vuksic <nebojsa.vuksic@jetbrains.com>

closes https://github.com/JetBrains/intellij-community/pull/3544

(cherry picked from commit 636a35d3ab1c8fff0566344a6542134fe8994a72)


(cherry picked from commit 42d85f991d8b2160e877dc78e3cc0a424d9b4f62)

IJ-MR-211495

GitOrigin-RevId: 0fd9c154a2463af9e44f38ec0a574bb814ad5d06
This commit is contained in:
Nebojsa Vuksic
2026-07-17 17:01:41 +00:00
committed by intellij-monorepo-bot
parent 89d3c37504
commit 49f92d712d
4 changed files with 148 additions and 9 deletions
+54
View File
@@ -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"
+27 -9
View File
@@ -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
+14
View File
@@ -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:
@@ -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 = "<version>"` 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: <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()
}