diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9fc2ec4..1fadb45 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,9 +8,6 @@ updates: day: "monday" open-pull-requests-limit: 5 groups: - kotlin: - patterns: - - "org.jetbrains.kotlin*" ktor: patterns: - "io.ktor*" @@ -24,6 +21,21 @@ updates: exposed: patterns: - "org.jetbrains.exposed*" + detekt: + patterns: + - "io.gitlab.arturbosch.detekt*" + - "com.github.marc0der*" + ktlint: + patterns: + - "com.pinterest.ktlint*" + jib: + patterns: + - "com.google.cloud.tools*" + release-plugin: + patterns: + - "org.eclipse.jgit*" + - "org.bouncycastle*" + - "org.slf4j*" # GitHub Actions - package-ecosystem: "github-actions" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dae75f..523d73f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,23 +10,19 @@ jobs: build: runs-on: ubuntu-latest + env: + KOTLIN_CLI_NO_WELCOME_BANNER: "1" + steps: - uses: actions/checkout@v6 with: - fetch-depth: 0 # Needed for Axion-release to determine version correctly - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - java-version: '21' - distribution: 'temurin' - cache: gradle - - - name: Grant execute permission for gradlew - run: chmod +x gradlew + fetch-depth: 0 # Needed for the release plugin to determine the version from git tags - - name: Build with Gradle - run: ./gradlew build --scan --info + - name: Build with Kotlin Toolchain + run: | + ./kotlin build + ./kotlin check + ./kotlin do package - name: Upload build artifacts uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b912692..63bd022 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: - main # Only release the latest commit; cancel superseded runs so back-to-back -# merges don't fail axion's aheadOfRemote check by racing each other. +# merges don't fail the release plugin's aheadOfRemote check by racing each other. concurrency: group: release-main cancel-in-progress: true @@ -19,6 +19,8 @@ jobs: build: name: "Release" runs-on: ubuntu-latest + env: + KOTLIN_CLI_NO_WELCOME_BANNER: "1" steps: - name: Install doctl uses: digitalocean/action-doctl@v2 @@ -33,23 +35,16 @@ jobs: with: fetch-depth: 0 - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - java-version: '21' - distribution: 'temurin' - cache: gradle - - - name: Gradle check - run: ./gradlew clean check + - name: Verify + run: ./kotlin check - - name: Gradle release - run: ./gradlew release + - name: Tag release + run: ./kotlin do release - - name: Propagate current release version + - name: Resolve current release version id: tag_version run: | - current_version=$(./gradlew currentVersion -q | grep "Project version" | awk '{print $3}') + current_version=$(./kotlin do currentVersion | awk '/currentVersion@release/ { v = $NF } END { print v }') echo "Version set to: $current_version" echo "version=$current_version" >> $GITHUB_OUTPUT @@ -57,5 +52,4 @@ jobs: run: | version="${{ steps.tag_version.outputs.version }}" commit_hash=$(git rev-parse --short=8 HEAD) - ./gradlew build jib -Djib.to.tags=$commit_hash,$version,latest - + JIB_TARGET_IMAGE_TAGS="$version,$commit_hash,latest" ./kotlin do jib diff --git a/.gitignore b/.gitignore index 60d154f..18e46fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,5 @@ -# Gradle -.gradle/ +# Kotlin Toolchain build/ -gradle-app.setting -!gradle-wrapper.jar # IDE files .idea/ diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 27c39bf..0000000 --- a/.mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": { - "gradle": { - "command": "java", - "args": [ - "-jar", - "/home/marco/mcp-servers/gradle-mcp-server/gradle-mcp-server-all.jar" - ], - "env": {} - } - } -} diff --git a/.sdkmanrc b/.sdkmanrc index 8e05285..acc7046 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,3 +1,4 @@ # Enable auto-env through the sdkman_auto_env config # Add key=value pairs of SDKs to use below java=21.0.7-tem +kotlintoolchain=0.11.0 diff --git a/CLAUDE.md b/CLAUDE.md index 874c3ff..dcfb863 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,25 +8,36 @@ SDKMAN Broker is a Kotlin-based Ktor application that brokers SDKMAN candidate a ## Build & Run -- **Run service:** `./gradlew run` (starts Ktor on port 8080; requires MongoDB and PostgreSQL — see Database Setup below) +Built with the Kotlin Toolchain (Amper engine). The `./kotlin` wrapper auto-provisions both the JDK and the toolchain; no `setup-java` or local install required. + +Built-in toolchain commands (`build`, `check`, `test`, `run`, `clean`) are invoked directly as `./kotlin `. Commands contributed by the local plugins under `plugins/` (e.g. `currentVersion`, `release`, `jib`, `ktlintFormat`) are invoked as `./kotlin do `. + +- **Run service:** `./kotlin run` (starts Ktor on port 8080; requires MongoDB and PostgreSQL — see Database Setup below) ## Validation Run after every implementation to get immediate feedback: -- **Clean build** `./gradlew clean` -- **Full check:** `./gradlew clean check` (clean -> compile → detekt → ktlintCheck → test) -- **Tests only:** `./gradlew test` -- **Lint only:** `./gradlew ktlintCheck` -- **Lint auto-fix:** `./gradlew ktlintFormat` -- **Static analysis:** `./gradlew detekt` (config in `detekt.yml`) -- **Build Docker image:** `./gradlew jib` (or `jibDockerBuild` for local) +- **Clean build:** `./kotlin clean` +- **Full check:** `./kotlin clean && ./kotlin check` (compile → detekt → ktlint → test) +- **Tests only:** `./kotlin test` +- **Lint auto-fix:** `./kotlin do ktlintFormat` +- **Static analysis:** detekt runs as part of `./kotlin check` (config in `detekt.yml`, plugin in `plugins/detekt/`) +- **Build Docker image:** `./kotlin do jib` (pushes to the registry in `module.yaml`; use `./kotlin do jibDockerBuild` for a local Docker daemon load) +- **Current version:** `./kotlin do currentVersion` (derived from git tags by the local release plugin) Tests spin up MongoDB and PostgreSQL via Testcontainers — no manual database setup is required to run the test suite. +## Build Configuration + +- **`module.yaml`** — root module config (dependencies, settings, plugin config). Layout is `maven-like` (`src/main/kotlin`, `src/test/kotlin`, etc.). +- **`project.yaml`** — registers all modules and local plugins. +- **`gradle/libs.versions.toml`** — Gradle-format version catalog reused as the `$libs.*` catalog (the directory name is historical; the file is consumed natively by the Kotlin Toolchain). +- **`plugins/{release,jib,detekt,ktlint}/`** — local plugins replacing the Gradle plugins of the same name. Reimplement behavior here rather than reaching for Gradle plugins. + ## Database Setup (Dev Server Only) -The dev server (`./gradlew run`) connects to MongoDB and PostgreSQL on localhost. Start them in Docker: +The dev server (`./kotlin run`) connects to MongoDB and PostgreSQL on localhost. Start them in Docker: ```bash docker run -d --restart=always -p=27017:27017 --name mongo mongo:3.2 diff --git a/README.md b/README.md index 6dc26ad..a646630 100644 --- a/README.md +++ b/README.md @@ -14,31 +14,44 @@ This application implements a health check endpoint that performs a deep health ### Prerequisites -- JDK 21 (Temurin recommended) - MongoDB (or use Docker) - Postgres (or use Docker) -- Gradle +- The Kotlin Toolchain (formerly Amper) — auto-provisioned by the bundled `./kotlin` wrapper. Installing it globally is optional. ### SDKMAN Setup -This project uses SDKMAN to manage the JDK version: +This project uses SDKMAN to manage the JDK and the Kotlin Toolchain CLI: ``` sdk env ``` +That installs the JDK declared in `.sdkmanrc` and the Kotlin Toolchain CLI. Alternatively, the `./kotlin` wrapper checked into the project root auto-provisions both on first use. + ### Building and Testing Build the project: ``` -./gradlew build +./kotlin build ``` Run tests: ``` -./gradlew check +./kotlin test +``` + +Run all verification checks (detekt + ktlint): + +``` +./kotlin check +``` + +Auto-format Kotlin sources: + +``` +./kotlin do ktlintFormat ``` ### Running Locally @@ -78,7 +91,7 @@ mongosh --eval 'db.getSiblingDB("sdkman").application.insertOne({ alive: "OK", s Run the application: ``` -./gradlew run +./kotlin run ``` ## API Endpoints @@ -138,14 +151,14 @@ This project uses GitHub Actions for CI/CD: - Builds a Docker image and pushes it to Digital Ocean Container Registry - The image is tagged with the version number, commit hash, and "latest" -The version is managed by the Axion Release Plugin based on Git tags. +The version is managed by the local `release` Kotlin Toolchain plugin (`plugins/release/`), which derives the version from Git tags. ### Checking the Current Version To check the current version of the application, run: ``` -./gradlew currentVersion +./kotlin do currentVersion ``` -This will display the current version as determined by the Axion Release Plugin. +This will display the current version as determined by the local release plugin. diff --git a/build.gradle.kts b/build.gradle.kts index 54152da..4cf3d93 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,146 +1,11 @@ -plugins { - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.serialization) - application - alias(libs.plugins.axion.release) - alias(libs.plugins.jib) - alias(libs.plugins.ktlint) - alias(libs.plugins.detekt) -} - -group = "io.sdkman" -// Use Axion Release Plugin to manage version -version = scmVersion.version - -// Configure the plugin -scmVersion { - tag { - prefix = "v" - versionSeparator = "" - } - checks { - uncommittedChanges = true - aheadOfRemote = true - } -} - -// Add task to generate release.properties file -tasks.register("generateReleaseProperties") { - description = "Generates release.properties file with the current project version" - group = "build" - - inputs.property("version", version) - val resourcesDir = tasks.processResources.get().destinationDir - outputs.file(File(resourcesDir, "release.properties")) - - doLast { - resourcesDir.mkdirs() - File(resourcesDir, "release.properties").writeText("release=${project.version}") - } -} - -// Make processResources depend on generateReleaseProperties -tasks.processResources { - dependsOn("generateReleaseProperties") -} - -repositories { - mavenCentral() - maven("https://jitpack.io") -} - -dependencies { - // Arrow for functional programming - implementation(libs.arrow.core) - - // Ktor server - implementation(libs.ktor.server.core) - implementation(libs.ktor.server.netty) - implementation(libs.ktor.server.content.negotiation) - implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.mongo.java.driver) - implementation(libs.postgresql) - implementation(libs.hikaricp) - implementation(libs.typesafe.config) - implementation(libs.logback.classic) - - // Exposed ORM - implementation(libs.exposed.core) { - exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") - } - implementation(libs.exposed.jdbc) { - exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") - } - implementation(libs.exposed.kotlin.datetime) { - exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") - } - - // Testing - testImplementation(libs.kotest.runner.junit5) - testImplementation(libs.kotest.assertions.core) - testImplementation(libs.arrow.core) - testImplementation(libs.ktor.server.test.host) - testImplementation(libs.ktor.client.okhttp) - testImplementation(libs.testcontainers.mongodb) - testImplementation(libs.testcontainers.postgresql) - testImplementation(libs.mockk) - testImplementation(libs.flyway.core) - testImplementation(libs.flyway.database.postgresql) - - detektPlugins(libs.detekt.rules) - compileOnly(libs.detekt.rules) -} - -tasks.withType { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) - freeCompilerArgs.add("-Xskip-metadata-version-check") - } -} - -tasks.withType { - useJUnitPlatform() - maxHeapSize = "512m" - maxParallelForks = 1 -} - -application { - mainClass.set("io.sdkman.broker.App") -} - -// Configure Jib for Docker image building -jib { - from { - image = "eclipse-temurin:21-jre-alpine" - } - to { - image = "registry.digitalocean.com/sdkman/sdkman-broker" - tags = setOf(version.toString(), "latest") - } - container { - ports = listOf("8080") - mainClass = application.mainClass.get() - jvmFlags = listOf("-Xms256m", "-Xmx512m") - // Add these environment variables as defaults, but they can be overridden at runtime - environment = - mapOf( - "MONGODB_URI" to "mongodb://localhost:27017", - "MONGODB_DATABASE" to "sdkman" - ) - // A sensible default for production containers - user = "1000" - } -} - -ktlint { - version.set("1.5.0") -} - -detekt { - buildUponDefaultConfig = true - config.setFrom(files("$projectDir/detekt.yml")) -} - -tasks.named("check") { - dependsOn("ktlintCheck") -} +// Intentionally empty. +// +// This project is built with the Kotlin Toolchain (Amper engine), not Gradle. +// All dependency versions live in `gradle/libs.versions.toml`, which the +// Toolchain consumes natively as its `$libs.*` catalog. +// +// This file exists so that GitHub Dependabot's `package-ecosystem: gradle` +// integration discovers the project: Dependabot's detector requires either a +// `build.gradle(.kts)` or `settings.gradle(.kts)` at the configured directory +// before it will scan `gradle/libs.versions.toml` for updatable dependencies. +// See `.github/dependabot.yml`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3e94ef..0622ba4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,4 @@ [versions] -# Plugins -kotlin = "2.3.21" -axion-release-plugin = "1.21.1" -jib-plugin = "3.4.1" -ktlint-plugin = "14.2.0" -detekt-plugin = "1.23.8" - # Libraries arrow = "2.2.2.1" ktor = "3.5.0" @@ -22,8 +15,18 @@ testcontainers = "1.21.4" mockk = "1.14.9" flyway = "12.6.2" -# Detekt rules +# Linters +detekt = "1.23.8" detekt-rules = "1.0.1" +ktlint = "1.5.0" + +# Local plugin runtime +jib-core = "0.28.1" +jgit = "7.0.0.202409031743-r" +# Pinned to the version contemporary with MINA SSHD 2.13.x (what jgit 7.0.0 ships against); +# without bouncycastle on the runtime classpath, SSH push fails with NoClassDefFoundError. +bouncycastle = "1.78.1" +slf4j = "2.0.13" [libraries] arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } @@ -56,12 +59,16 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" } flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" } +detekt-cli = { module = "io.gitlab.arturbosch.detekt:detekt-cli", version.ref = "detekt" } detekt-rules = { module = "com.github.marc0der:detekt-rules", version.ref = "detekt-rules" } +ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" } + +jib-core = { module = "com.google.cloud.tools:jib-core", version.ref = "jib-core" } + +jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" } +jgit-ssh-apache = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache", version.ref = "jgit" } + +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +bouncycastle-bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } -[plugins] -kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -axion-release = { id = "pl.allegro.tech.build.axion-release", version.ref = "axion-release-plugin" } -jib = { id = "com.google.cloud.tools.jib", version.ref = "jib-plugin" } -ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" } -detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt-plugin" } +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 9bbc975..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 37f853b..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index faf9300..0000000 --- a/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/kotlin b/kotlin new file mode 100755 index 0000000..1313759 --- /dev/null +++ b/kotlin @@ -0,0 +1,286 @@ +#!/bin/sh + +# +# Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +# + +# Possible environment variables: +# KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from. +# default: https://packages.jetbrains.team/maven/p/amper/amper +# KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the JRE to run the Kotlin CLI +# default: https:/ +# KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution +# KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation) +# KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application) +# KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value + +set -e -u + +# The version of the Kotlin Toolchain (and CLI) distribution to provision and use +kotlin_cli_version=0.11.0 +# Establish chain of trust from here by specifying exact checksum of Kotlin Toolchain (and CLI) distribution to be run +kotlin_cli_sha256=ff872a5bf42ad1a8fac90ccca6ac38a4d4a6aafefc39167860f66a77b1653d74 + +KOTLIN_CLI_DOWNLOAD_ROOT="${KOTLIN_CLI_DOWNLOAD_ROOT:-https://packages.jetbrains.team/maven/p/amper/amper}" + +die() { + echo >&2 + echo "$@" >&2 + echo >&2 + exit 1 +} + +# usage: check_sha SOURCE_MONIKER FILE SHA_CHECKSUM SHA_SIZE +# $1 SOURCE_MONIKER (e.g. url) +# $2 FILE +# $3 SHA hex string +# $4 SHA size in bits (256, 512, ...) +check_sha() { + sha_size=$4 + if command -v shasum >/dev/null 2>&1; then + echo "$3 *$2" | shasum -a "$sha_size" --status -c || { + die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $(shasum --binary -a "$sha_size" "$2" | awk '{print $1}')" + } + return 0 + fi + + shaNsumCommand="sha${sha_size}sum" + if command -v "$shaNsumCommand" >/dev/null 2>&1; then + # discard the output as sha*sum may print redundant warnings in some versions + echo "$3 *$2" | $shaNsumCommand -w -c >/dev/null 2>&1 || { + die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $($shaNsumCommand "$2" | awk '{print $1}')" + } + return 0 + fi + + echo "Both 'shasum' and 'sha${sha_size}sum' utilities are missing. Please install one of them" + return 1 +} + + +download_and_extract() { + moniker="$1" + file_url="$2" + file_sha="$3" + sha_size="$4" + cache_dir="$5" + extract_dir="$6" + show_banner_on_cache_miss="$7" + + if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then + # Everything is up-to-date in $extract_dir, do nothing + return 0; + fi + + mkdir -p "$cache_dir" + + # Take a lock for the download of this file + short_sha=$(echo "$file_sha" | cut -c1-32) # cannot use the ${short_sha:0:32} syntax in regular /bin/sh + download_lock_file="$cache_dir/download-${short_sha}.lock" + process_lock_file="$cache_dir/download-${short_sha}.$$.lock" + echo $$ >"$process_lock_file" + while ! ln "$process_lock_file" "$download_lock_file" 2>/dev/null; do + lock_owner=$(cat "$download_lock_file" 2>/dev/null || true) + # We use `kill -0` instead of `ps -p` as the first one is more portable + if [ -n "$lock_owner" ] && kill -0 "$lock_owner" >/dev/null; then + echo "Another Kotlin CLI instance (pid $lock_owner) is downloading $moniker. Awaiting the result..." + sleep 1 + elif [ -n "$lock_owner" ] && [ "$(cat "$download_lock_file" 2>/dev/null)" = "$lock_owner" ]; then + rm -f "$download_lock_file" + # We don't want to simply loop again here, because multiple concurrent processes may face this at the same time, + # which means the 'rm' command above from another script could delete our new valid lock file. Instead, we just + # ask the user to try again. This doesn't 100% eliminate the race, but the probability of issues is drastically + # reduced because it would involve 4 processes with perfect timing. We can revisit this later. + die "Another Kotlin CLI instance (pid $lock_owner) locked the download of $moniker, but is no longer running. The lock file is now removed, please try again." + fi + done + + # shellcheck disable=SC2064 + trap "rm -f \"$download_lock_file\"" EXIT + rm -f "$process_lock_file" + + unlock_and_cleanup() { + rm -f "$download_lock_file" + trap - EXIT + return 0 + } + + if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then + # Everything is up-to-date in $extract_dir, just release the lock + unlock_and_cleanup + return 0; + fi + + if [ "$show_banner_on_cache_miss" = "true" ] && [ -z "${KOTLIN_CLI_NO_WELCOME_BANNER:-}" ]; then + echo + cat </dev/null 2>&1; then + if [ -t 1 ]; then CURL_PROGRESS="--progress-bar"; else CURL_PROGRESS="--silent --show-error"; fi + # shellcheck disable=SC2086 + curl $CURL_PROGRESS -L --fail --retry 5 --connect-timeout 30 --output "${temp_file}" "$file_url" + elif command -v wget >/dev/null 2>&1; then + if [ -t 1 ]; then WGET_PROGRESS=""; else WGET_PROGRESS="-nv"; fi + wget $WGET_PROGRESS --tries=5 --connect-timeout=30 --read-timeout=120 -O "${temp_file}" "$file_url" + else + die "ERROR: Please install 'wget' or 'curl', as one of them is required to download $moniker" + fi + + check_sha "$file_url" "$temp_file" "$file_sha" "$sha_size" + + rm -rf "$extract_dir" + mkdir -p "$extract_dir" + + case "$file_url" in + *".zip") + if command -v unzip >/dev/null 2>&1; then + unzip -q "$temp_file" -d "$extract_dir" + else + die "ERROR: Please install 'unzip', which is required to extract $moniker" + fi ;; + *) + if command -v tar >/dev/null 2>&1; then + tar -x -f "$temp_file" -C "$extract_dir" + else + die "ERROR: Please install 'tar', which is required to extract $moniker" + fi ;; + esac + + rm -f "$temp_file" + + echo "$file_sha" >"$extract_dir/.flag" + + # Unlock and cleanup the lock file + unlock_and_cleanup + + echo "Download complete." + echo +} + + +# ********** Project-local version detection ********** + +# 1. Search upwards for an executable `amper` file and/or `project.yaml` +# Sets wrapper_script to the found wrapper path, or empty string if not found. +find_project_context() { + wrapper_script="" + this_script="$(realpath "$0")" + project_dir=$(pwd) + while [ "$project_dir" != "/" ] && [ -n "$project_dir" ]; do + wrapper_candidate="$project_dir/kotlin" + if [ "$this_script" = "$wrapper_candidate" ]; then + # Found itself (local wrapper case), no need to update any version or search further. + return 1 + fi + + if [ -f "$wrapper_candidate" ] && [ -x "$wrapper_candidate" ]; then + # Found the wrapper — check that a project context exists alongside it + if [ -f "$project_dir/project.yaml" ] || [ -f "$project_dir/module.yaml" ]; then + wrapper_script="$wrapper_candidate" + return 0 + else + echo "WARNING: Found wrapper script '$wrapper_candidate', but no project.yaml or module.yaml near it. Skipping." >&2 + # Continue the search + fi + elif [ -f "$project_dir/project.yaml" ]; then + # Found project.yaml but no executable wrapper alongside it + echo "WARNING: Found a project.yaml in '$project_dir', but the wrapper script is missing; using Kotlin Toolchain v$kotlin_cli_version." >&2 + return 1 + fi + + project_dir=$(dirname "$project_dir") + done + # Do not check root '/' - it's an unlikely candidate for a project + + return 1 +} + +parse_project_context() { + # Parse kotlin_cli_version and kotlin_cli_sha256 from "$wrapper_script" without executing it. + parsed_kotlin_cli_version=$( + sed -n 's/^kotlin_cli_version=\([A-Za-z0-9._+-]\{1,\}\)[[:space:]]*$/\1/p' "$wrapper_script" \ + | head -n 1 + ) + parsed_kotlin_cli_sha256=$( + sed -n 's/^kotlin_cli_sha256=\([0-9a-fA-F]\{64\}\)[[:space:]]*$/\1/p' "$wrapper_script" \ + | head -n 1 + ) + + if [ -z "$parsed_kotlin_cli_version" ]; then + echo "ERROR: Suspicious local wrapper script: failed to detect the distribution version in '$wrapper_script'" >&2 + return 1 + fi + if [ -z "$parsed_kotlin_cli_sha256" ]; then + echo "ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '$wrapper_script'" >&2 + return 1 + fi + + # overwrite builtin values and proceed + kotlin_cli_version=$parsed_kotlin_cli_version + kotlin_cli_sha256=$parsed_kotlin_cli_sha256 + return 0 +} + +if [ -z "${KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION:-}" ]; then + find_project_context && parse_project_context +fi + +# ********** System detection ********** + +kernelName=$(uname -s) +case "$kernelName" in + Darwin* ) + default_kotlin_cli_cache_dir="$HOME/Library/Caches/JetBrains/Kotlin/cli" + ;; + Linux* ) + default_kotlin_cli_cache_dir="$HOME/.cache/JetBrains/Kotlin/cli" + ;; + CYGWIN* | MSYS* | MINGW* ) + if command -v cygpath >/dev/null 2>&1; then + default_kotlin_cli_cache_dir=$(cygpath -u "$LOCALAPPDATA\JetBrains\Kotlin\cli") + else + die "The 'cypath' command is not available, but the Kotlin CLI needs it. Use kotlin.bat instead, or try a Cygwin or MSYS environment." + fi + ;; + *) + die "Unsupported platform $kernelName" + ;; +esac + +kotlin_cli_cache_dir="${KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:-$default_kotlin_cli_cache_dir}" + +# ********** Provision the Kotlin Toolchain distribution ********** + +kotlin_cli_url="$KOTLIN_CLI_DOWNLOAD_ROOT/org/jetbrains/kotlin/kotlin-cli/$kotlin_cli_version/kotlin-cli-$kotlin_cli_version-dist.tgz" +kotlin_cli_target_dir="$kotlin_cli_cache_dir/kotlin-cli-$kotlin_cli_version" +download_and_extract "Kotlin Toolchain distribution v$kotlin_cli_version" "$kotlin_cli_url" "$kotlin_cli_sha256" 256 "$kotlin_cli_cache_dir" "$kotlin_cli_target_dir" "true" + +# ********** Launch the Kotlin CLI ********** + +launcher_script="$kotlin_cli_target_dir/bin/launcher.sh" + +KOTLIN_CLI_WRAPPER_PATH="$(realpath "$0")" \ +exec /bin/sh "$launcher_script" "$@" diff --git a/kotlin.bat b/kotlin.bat new file mode 100644 index 0000000..7cd0acc --- /dev/null +++ b/kotlin.bat @@ -0,0 +1,257 @@ +@echo off + +@rem +@rem Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@rem + +@rem Possible environment variables: +@rem KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from +@rem default: https://packages.jetbrains.team/maven/p/amper/amper +@rem KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the Kotlin CLI JRE from. +@rem default: https:/ +@rem KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution +@rem KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation) +@rem KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application) +@rem KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value + +setlocal + +@rem The version of the Kotlin Toolchain distribution to provision and use +set kotlin_cli_version=0.11.0 +@rem Establish chain of trust from here by specifying the exact checksum of the Kotlin Toolchain distribution to be run +set kotlin_cli_sha256=ff872a5bf42ad1a8fac90ccca6ac38a4d4a6aafefc39167860f66a77b1653d74 + +if not defined KOTLIN_CLI_DOWNLOAD_ROOT set KOTLIN_CLI_DOWNLOAD_ROOT=https://packages.jetbrains.team/maven/p/amper/amper +if not defined KOTLIN_CLI_BOOTSTRAP_CACHE_DIR set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%LOCALAPPDATA%\JetBrains\Kotlin\cli +@rem remove trailing \ if present +if [%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~-1%] EQU [\] set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~0,-1% + +goto :after_function_declarations + +REM ********** Download and extract any zip or .tar.gz archive ********** + +:download_and_extract +setlocal + +set moniker=%~1 +set url=%~2 +set target_dir=%~3 +set sha=%~4 +set sha_size=%~5 +set show_banner_on_cache_miss=%~6 + +set flag_file=%target_dir%\.flag +if exist "%flag_file%" ( + set /p current_flag=<"%flag_file%" + if "%current_flag%" == "%sha%" exit /b +) + +setlocal enableDelayedExpansion +set NL=^ + + +@rem two empty lines required above for the NL character + +@rem We have to build the welcome banner here as an env var because we +@rem can't pass a multiline string through the single line powershell +set welcome_banner=!NL! ^ +Welcome to !NL! ^ + !NL! ^ +@@@ @@@@ @@@ @@@ !NL! ^ +@@@ @@@@ @@@ @@@ @@@ !NL! ^ +@@@ #@@@^" ,@@@ @@@ !NL! ^ +@@@ ,@@@%% ,@@@@@@@, @@@@@@@@@@ @@@ @@@ @@@ ,@@@@@, !NL! ^ +@@@ @@@@ @@@@@%%^"%%@@@@@ @@@@@@@@@@ @@@ @@@ @@@@@@%%%%@@@@@ !NL! ^ +@@@@@@%% @@@%% %%@@@ @@@ @@@ @@@ @@@@ %%@@%% !NL! ^ +@@@ @@@@= #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^ +@@@ @@@@ #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^ +@@@ *@@@%% @@@@ @@@@ @@@ @@@ @@@ @@@ @@@ !NL! ^ +@@@ %%@@@= %%@@@@*,*@@@@%% @@@@### @@@ @@@ @@@ @@@ !NL! ^ +@@@ @@@@ ^"@@@@@@@^" %%@@@@@ @@@ @@@ @@@ @@@ !NL! + +@rem This multiline string is actually passed as a single line to powershell, meaning #-comments are not possible. +@rem So here are a few comments about the code below: +@rem - we need to support both .zip and .tar.gz archives (for the Kotlin Toolchain distribution and the JRE) +@rem - tar should be present in all Windows machines since 2018 (and usable from both cmd and powershell) +@rem - tar requires the destination dir to exist +@rem - We use (New-Object Net.WebClient).DownloadFile instead of Invoke-WebRequest for performance. See the issue +@rem https://github.com/PowerShell/PowerShell/issues/16914, which is still not fixed in Windows PowerShell 5.1 +@rem - DownloadFile requires the directories in the destination file's path to exist +set download_and_extract_ps1= ^ +Set-StrictMode -Version 3.0; ^ +$ErrorActionPreference = 'Stop'; ^ + ^ +$createdNew = $false; ^ +$lock = New-Object System.Threading.Mutex($true, ('Global\kotlin-cli-bootstrap.' + '%target_dir%'.GetHashCode().ToString()), [ref]$createdNew); ^ +if (-not $createdNew) { ^ + Write-Host 'Another Kotlin CLI instance is bootstrapping. Waiting for our turn...'; ^ + [void]$lock.WaitOne(); ^ +} ^ + ^ +try { ^ + if ((Get-Content '%flag_file%' -ErrorAction Ignore) -ne '%sha%') { ^ + if (('%show_banner_on_cache_miss%' -eq 'true') -and [string]::IsNullOrEmpty('%KOTLIN_CLI_NO_WELCOME_BANNER%')) { ^ + Write-Host \"$env:welcome_banner\"; ^ + Write-Host ''; ^ + Write-Host 'This is the first run of the Kotlin CLI v%kotlin_cli_version%, so we need to download the Kotlin Toolchain.'; ^ + Write-Host 'Please give us a few seconds now, subsequent runs will be faster.'; ^ + Write-Host ''; ^ + } ^ + $temp_file = '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\' + [System.IO.Path]::GetRandomFileName(); ^ + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ^ + Write-Host 'Downloading %moniker%...'; ^ + [void](New-Item '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%' -ItemType Directory -Force); ^ + if (Get-Command curl.exe -errorAction SilentlyContinue) { ^ + curl.exe -L --silent --show-error --fail --output $temp_file '%url%'; ^ + } else { ^ + (New-Object Net.WebClient).DownloadFile('%url%', $temp_file); ^ + } ^ + ^ + $actualSha = (Get-FileHash -Algorithm SHA%sha_size% -Path $temp_file).Hash.ToString(); ^ + if ($actualSha -ne '%sha%') { ^ + $writeErr = if ($Host.Name -eq 'ConsoleHost') { [Console]::Error.WriteLine } else { $host.ui.WriteErrorLine } ^ + $writeErr.Invoke(\"ERROR: Checksum mismatch for $temp_file (downloaded from %url%): expected checksum %sha% but got $actualSha\"); ^ + exit 1; ^ + } ^ + ^ + if (Test-Path '%target_dir%') { ^ + Remove-Item '%target_dir%' -Recurse; ^ + } ^ + if ($temp_file -like '*.zip') { ^ + Add-Type -A 'System.IO.Compression.FileSystem'; ^ + [IO.Compression.ZipFile]::ExtractToDirectory($temp_file, '%target_dir%'); ^ + } else { ^ + [void](New-Item '%target_dir%' -ItemType Directory -Force); ^ + tar -xzf $temp_file -C '%target_dir%'; ^ + } ^ + Remove-Item $temp_file; ^ + ^ + Set-Content '%flag_file%' -Value '%sha%'; ^ + Write-Host 'Download complete.'; ^ + Write-Host ''; ^ + } ^ +} ^ +finally { ^ + $lock.ReleaseMutex(); ^ +} + +rem We reset the PSModulePath in case this batch script was called from PowerShell Core +rem See https://github.com/PowerShell/PowerShell/issues/18108#issuecomment-2269703022 +set PSModulePath= +set powershell=%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe +"%powershell%" -NonInteractive -NoProfile -NoLogo -Command %download_and_extract_ps1% +if errorlevel 1 exit /b 1 +exit /b 0 + +:find_project_context +@rem Search upwards for a kotlin.bat wrapper file and/or project.yaml +@rem Sets wrapper_script to the found wrapper path, or empty string if not found. +@rem Returns errorlevel 0 if a valid wrapper (that is not this script itself) was found, 1 otherwise. +set wrapper_script= +set this_script=%~f0 +set project_dir=%CD% + +:find_loop +set wrapper_candidate=%project_dir%\kotlin.bat +if "%this_script%"=="%wrapper_candidate%" ( + @rem Found itself (local wrapper case), no need to update any version or search further. + exit /b 1 +) + +if exist "%wrapper_candidate%" ( + @rem Found a wrapper — check that a project context exists alongside it + if exist "%project_dir%\project.yaml" ( + set wrapper_script=%wrapper_candidate% + exit /b 0 + ) + if exist "%project_dir%\module.yaml" ( + set wrapper_script=%wrapper_candidate% + exit /b 0 + ) + echo WARNING: Found wrapper script '%wrapper_candidate%', but no project.yaml or module.yaml near it. Skipping. >&2 + @rem Continue the search + goto :find_next_parent +) + +if exist "%project_dir%\project.yaml" ( + @rem Found project.yaml but no wrapper alongside it + echo WARNING: Found a project.yaml in '%project_dir%', but the wrapper script is missing; using Kotlin Toolchain v$kotlin_cli_version. >&2 + exit /b 1 +) + +:find_next_parent +@rem Move to parent directory +for %%P in ("%project_dir%\..") do set parent_dir=%%~fP +if "%parent_dir%"=="%project_dir%" ( + @rem Reached the root, stop searching + exit /b 1 +) +set project_dir=%parent_dir% +goto :find_loop + +:parse_project_context +@rem Parse kotlin_cli_version and kotlin_cli_sha256 from the found wrapper_script without executing it. +set parsed_kotlin_cli_version= +set parsed_kotlin_cli_sha256= + +for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_version=[A-Za-z0-9._+-]*$" "%wrapper_script%"') do ( + if not defined parsed_kotlin_cli_version set parsed_kotlin_cli_version=%%A +) +for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_sha256=[0-9a-fA-F]*$" "%wrapper_script%"') do ( + if not defined parsed_kotlin_cli_sha256 set parsed_kotlin_cli_sha256=%%A +) + +if not defined parsed_kotlin_cli_version ( + echo ERROR: Suspicious local wrapper script: failed to detect the distribution version in '%wrapper_script%' >&2 + exit /b 1 +) +if not defined parsed_kotlin_cli_sha256 ( + echo ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '%wrapper_script%' >&2 + exit /b 1 +) + +@rem Overwrite builtin values and proceed +set kotlin_cli_version=%parsed_kotlin_cli_version% +set kotlin_cli_sha256=%parsed_kotlin_cli_sha256% +exit /b 0 + +:fail +echo ERROR: Kotlin CLI bootstrap failed, see errors above +exit /b 1 + +:after_function_declarations + +REM ********** Project-local version detection ********** + +if defined KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION goto :after_local_version_detection + +call :find_project_context +if errorlevel 1 goto :after_local_version_detection +call :parse_project_context +if errorlevel 1 goto fail +:after_local_version_detection + +REM ********** Provision the Kotlin Toolchain distribution ********** + +set kotlin_cli_url=%KOTLIN_CLI_DOWNLOAD_ROOT%/org/jetbrains/kotlin/kotlin-cli/%kotlin_cli_version%/kotlin-cli-%kotlin_cli_version%-dist.tgz +set kotlin_cli_target_dir=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\kotlin-cli-%kotlin_cli_version% +call :download_and_extract "Kotlin Toolchain distribution v%kotlin_cli_version%" "%kotlin_cli_url%" "%kotlin_cli_target_dir%" "%kotlin_cli_sha256%" "256" "true" +if errorlevel 1 goto fail + +REM ********** Launch the Kotlin CLI ********** + +rem Determine the correct busybox binary based on architecture +if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64a.exe +) else if "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( + set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64u.exe +) else ( + echo Unsupported architecture %PROCESSOR_ARCHITECTURE% >&2 + goto fail +) + +rem We use busybox here because it doesn't reinterpret the user-passed command-line arguments (that we pass via %*). +rem Also this way we can use the unified launcher script (.sh) +set KOTLIN_CLI_WRAPPER_PATH=%~f0 +"%busybox_exe%" sh "%kotlin_cli_target_dir%\bin\launcher.sh" %* +exit /B %ERRORLEVEL% diff --git a/module.yaml b/module.yaml new file mode 100644 index 0000000..b24b4ff --- /dev/null +++ b/module.yaml @@ -0,0 +1,69 @@ +product: jvm/app + +layout: maven-like + +repositories: + - id: jitpack + url: https://jitpack.io + +plugins: + release: enabled + jib: + enabled: true + container: + mainClass: io.sdkman.broker.App + jvmArgs: ["-Xms256m", "-Xmx512m"] + ports: ["8080"] + user: "1000" + environment: + MONGODB_URI: "mongodb://localhost:27017" + MONGODB_DATABASE: "sdkman" + baseImage: + fullName: eclipse-temurin:21-jre-alpine + targetImage: + name: registry.digitalocean.com/sdkman/sdkman-broker + tags: ["latest"] + detekt: + enabled: true + configFile: detekt.yml + buildUponDefaultConfig: true + rulesetClasspath: + - $libs.detekt.rules + ktlint: enabled + package: enabled + +settings: + jvm: + mainClass: io.sdkman.broker.App + jdk: + version: 21 + kotlin: + serialization: json + freeCompilerArgs: ["-Xskip-metadata-version-check"] + +dependencies: + - $libs.arrow.core + - $libs.ktor.server.core + - $libs.ktor.server.netty + - $libs.ktor.server.content.negotiation + - $libs.ktor.serialization.kotlinx.json + - $libs.mongo.java.driver + - $libs.postgresql + - $libs.hikaricp + - $libs.typesafe.config + - $libs.logback.classic + - $libs.exposed.core + - $libs.exposed.jdbc + - $libs.exposed.kotlin.datetime + +test-dependencies: + - $libs.kotest.runner.junit5 + - $libs.kotest.assertions.core + - $libs.arrow.core + - $libs.ktor.server.test.host + - $libs.ktor.client.okhttp + - $libs.testcontainers.mongodb + - $libs.testcontainers.postgresql + - $libs.mockk + - $libs.flyway.core + - $libs.flyway.database.postgresql diff --git a/plugins/detekt/module.yaml b/plugins/detekt/module.yaml new file mode 100644 index 0000000..e82482a --- /dev/null +++ b/plugins/detekt/module.yaml @@ -0,0 +1,15 @@ +product: jvm/amper-plugin + +description: A plugin to run the Detekt linter. + +pluginInfo: + settingsClass: org.jetbrains.amper.plugins.detekt.Settings + +# No hard dependency on Detekt itself — resolved dynamically via Classpath in plugin.yaml + +settings: + jvm: + jdk: + version: 21 + kotlin: + languageVersion: 2.1 diff --git a/plugins/detekt/plugin.yaml b/plugins/detekt/plugin.yaml new file mode 100644 index 0000000..98cc8b6 --- /dev/null +++ b/plugins/detekt/plugin.yaml @@ -0,0 +1,25 @@ +tasks: + analyze: + action: !org.jetbrains.amper.plugins.detekt.runDetectForOutput + commonParameters: + settings: ${pluginSettings} + sources: ${module.kotlinJavaSources} + moduleClasspath: ${module.compileClasspath} + detektClasspath: + - $libs.detekt.cli + rulesetClasspath: ${pluginSettings.rulesetClasspath} + baselineFile: ${module.rootDir}/detekt/baseline.xml + outputXmlReport: ${taskOutputDir}/report.xml + + updateBaseline: + action: !org.jetbrains.amper.plugins.detekt.runDetectForBaseline + commonParameters: ${analyze.action.commonParameters} + outputBaselineFile: ${analyze.action.baselineFile} + +checks: + - performedBy: analyze + name: detekt + +commands: + - name: updateDetektBaseline + performedBy: updateBaseline diff --git a/plugins/detekt/src/CommonRunDetectSettings.kt b/plugins/detekt/src/CommonRunDetectSettings.kt new file mode 100644 index 0000000..53bbd97 --- /dev/null +++ b/plugins/detekt/src/CommonRunDetectSettings.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package org.jetbrains.amper.plugins.detekt + +import org.jetbrains.amper.plugins.Classpath +import org.jetbrains.amper.plugins.Configurable +import org.jetbrains.amper.plugins.ModuleSources + +@Configurable +interface CommonRunDetectSettings { + val settings: Settings + val sources: ModuleSources + val moduleClasspath: Classpath + val detektClasspath: Classpath + val rulesetClasspath: Classpath +} diff --git a/plugins/detekt/src/Settings.kt b/plugins/detekt/src/Settings.kt new file mode 100644 index 0000000..8a523a7 --- /dev/null +++ b/plugins/detekt/src/Settings.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package org.jetbrains.amper.plugins.detekt + +import org.jetbrains.amper.plugins.Classpath +import org.jetbrains.amper.plugins.Configurable +import java.nio.file.Path + +@Configurable +interface Settings { + /** + * Optional path to a detekt.yml configuration file. + */ + val configFile: Path? + + /** + * When using a custom [config file][configFile], the default values are ignored unless you also set this flag. + */ + val buildUponDefaultConfig: Boolean get() = false + + /** + * Run detekt with type resolution enabled (passes the module's compile classpath via `--classpath`). + * + * Type resolution activates additional rules that depend on type information, but some of those rules + * (notably `UnreachableCode`) are known to produce false positives. Defaults to `false` to match the + * behaviour of the Gradle detekt plugin's default `detekt` task; set to `true` to opt in. + */ + val useTypeResolution: Boolean get() = false + + /** + * Extra rule-set jars to load into Detekt via `--plugins`. Each entry is either a Maven coordinate + * (e.g. `com.example:my-detekt-rules:1.0.0`) or a catalog reference (e.g. `$libs.detekt.rules`). + */ + val rulesetClasspath: Classpath +} diff --git a/plugins/detekt/src/runDetekt.kt b/plugins/detekt/src/runDetekt.kt new file mode 100644 index 0000000..09aeaff --- /dev/null +++ b/plugins/detekt/src/runDetekt.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package org.jetbrains.amper.plugins.detekt + +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.Output +import org.jetbrains.amper.plugins.TaskAction +import java.io.File +import java.nio.file.Path +import kotlin.concurrent.thread +import kotlin.io.path.createParentDirectories +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile +import kotlin.io.path.pathString + +@TaskAction +fun runDetectForOutput( + @Input commonParameters: CommonRunDetectSettings, + @Input(inferTaskDependency = false) baselineFile: Path, + @Output outputXmlReport: Path, +) = runDetekt( + commonParameters = commonParameters, + baselineFile = baselineFile, + outputXmlReport = outputXmlReport, + generateBaseline = false, +) + +@TaskAction +fun runDetectForBaseline( + @Input commonParameters: CommonRunDetectSettings, + @Output outputBaselineFile: Path, +) = runDetekt( + commonParameters = commonParameters, + baselineFile = outputBaselineFile, + outputXmlReport = null, + generateBaseline = true, +) + +private fun runDetekt( + commonParameters: CommonRunDetectSettings, + baselineFile: Path, + outputXmlReport: Path?, + generateBaseline: Boolean, +) { + val reportDiagnostics = !generateBaseline + + if (commonParameters.sources.sourceDirectories.isEmpty()) { + return + } + + val inputDirs = commonParameters.sources.sourceDirectories + .filter { it.isDirectory() } + .joinToString(File.pathSeparator) + + outputXmlReport?.createParentDirectories() + + // detekt uses output with the same --baseline argument + + val args = mutableListOf().apply { + add(DETEKT_MAIN_CLASS) + add("--input") + add(inputDirs) + + // Provide classpath for type resolution if opted in (see Settings.useTypeResolution). + if (commonParameters.settings.useTypeResolution) { + val cp = commonParameters.moduleClasspath.resolvedFiles + if (cp.isNotEmpty()) { + add("--classpath") + add(cp.joinToString(File.pathSeparator)) + } + } + + commonParameters.settings.configFile?.let { + add("--config") + add(it.pathString) + } + + if (commonParameters.settings.buildUponDefaultConfig) { + add("--build-upon-default-config") + } + + val rulesetJars = commonParameters.rulesetClasspath.resolvedFiles + if (rulesetJars.isNotEmpty()) { + add("--plugins") + add(rulesetJars.joinToString(",") { it.pathString }) + } + + if (generateBaseline) { + baselineFile.createParentDirectories() + add("--create-baseline") + add("--baseline") + add(baselineFile.pathString) + } + + if (!generateBaseline && baselineFile.isRegularFile()) { + add("--baseline") + add(baselineFile.pathString) + } + + if (outputXmlReport != null) { + add("--report") + add("xml:${outputXmlReport}") + } + } + + // Build Java command line that launches Detekt CLI inside a separate process + val detektCp = commonParameters.detektClasspath.resolvedFiles.joinToString(File.pathSeparator) + val commandLine = buildList { + add(ProcessHandle.current().info().command().orElse("java")) + add("-cp") + add(detektCp) + addAll(args) + } + + // FIXME: AMPER-4912 Introduce a process launching facility (shared with other plugins) + val process = ProcessBuilder(commandLine) + // detekt doesn't work well on our JRE - it uses deprecated JVM features; discard these messages. + // TODO: update to detect 2.0.0 when it is released + .redirectError(ProcessBuilder.Redirect.DISCARD) + .redirectOutput(if (reportDiagnostics) ProcessBuilder.Redirect.PIPE else ProcessBuilder.Redirect.DISCARD) + .start() + val capture = if (reportDiagnostics) { + thread { process.inputStream.copyTo(System.out) } + } else null + + val exitCode = process + .waitFor() + + capture?.join() + + if (reportDiagnostics) { + process.inputStream.copyTo(System.err) + + check(exitCode == 0) { + "Detekt terminated with code = $exitCode. See the log above for details." + } + } +} + +private const val DETEKT_MAIN_CLASS = "io.gitlab.arturbosch.detekt.cli.Main" diff --git a/plugins/jib/module.yaml b/plugins/jib/module.yaml new file mode 100644 index 0000000..13d21a2 --- /dev/null +++ b/plugins/jib/module.yaml @@ -0,0 +1,7 @@ +product: jvm/amper-plugin + +pluginInfo: + settingsClass: io.sdkman.kotlintoolchain.plugins.jib.JibSettings + +dependencies: + - $libs.jib.core diff --git a/plugins/jib/plugin.yaml b/plugins/jib/plugin.yaml new file mode 100644 index 0000000..c6dadc7 --- /dev/null +++ b/plugins/jib/plugin.yaml @@ -0,0 +1,25 @@ +tasks: + jib: + action: !io.sdkman.kotlintoolchain.plugins.jib.buildAndPush + runtimeClasspath: ${module.runtimeClasspath} + container: ${pluginSettings.container} + baseImage: ${pluginSettings.baseImage} + targetImage: ${pluginSettings.targetImage} + jibDockerBuild: + action: !io.sdkman.kotlintoolchain.plugins.jib.buildToDockerDaemon + runtimeClasspath: ${module.runtimeClasspath} + container: ${pluginSettings.container} + baseImage: ${pluginSettings.baseImage} + targetImageName: ${pluginSettings.targetImage.name} + jibBuildTar: + action: !io.sdkman.kotlintoolchain.plugins.jib.buildTar + runtimeClasspath: ${module.runtimeClasspath} + container: ${pluginSettings.container} + baseImage: ${pluginSettings.baseImage} + outputTar: ${taskOutputDir}/${module.name}-oci-image.tar + targetImageName: ${pluginSettings.targetImage.name} + + +commands: + - name: jib + performedBy: jib diff --git a/plugins/jib/src/Jib.kt b/plugins/jib/src/Jib.kt new file mode 100644 index 0000000..57e6b5f --- /dev/null +++ b/plugins/jib/src/Jib.kt @@ -0,0 +1,115 @@ +package io.sdkman.kotlintoolchain.plugins.jib + +import com.google.cloud.tools.jib.api.* +import com.google.cloud.tools.jib.frontend.CredentialRetrieverFactory +import org.jetbrains.amper.plugins.* +import java.nio.file.Path + +@TaskAction +fun buildAndPush( + @Input runtimeClasspath: Classpath, + container: ContainerSettings, + baseImage: BaseImageSettings, + targetImage: TargetImageSettings, +) { + val containerizer = Containerizer.to(targetImage.toRegistryImages()) + targetImage.effectiveTags().forEach { containerizer.withAdditionalTag(it) } + jibContainerBuilder(runtimeClasspath, container, baseImage).containerize(containerizer) +} + +@TaskAction +fun buildTar( + @Input runtimeClasspath: Classpath, + container: ContainerSettings, + baseImage: BaseImageSettings, + targetImageName: String, + @Output outputTar: Path, +) { + jibContainerBuilder(runtimeClasspath, container, baseImage) + .containerize(Containerizer.to(TarImage.at(outputTar).named(targetImageName))) +} + +@TaskAction +fun buildToDockerDaemon( + @Input runtimeClasspath: Classpath, + container: ContainerSettings, + baseImage: BaseImageSettings, + targetImageName: String, +) { + jibContainerBuilder(runtimeClasspath, container, baseImage) + .containerize(Containerizer.to(DockerDaemonImage.named(ImageReference.parse(targetImageName)))) +} + +private fun jibContainerBuilder( + runtimeClasspath: Classpath, + container: ContainerSettings, + baseImage: BaseImageSettings, +): JibContainerBuilder = JavaContainerBuilder.from(baseImage.toRegistryImage()) + .addDependencies(runtimeClasspath.resolvedFiles) + .addJvmFlags(container.jvmArgs) + .setMainClass(container.mainClass) + .toContainerBuilder() + .apply { + if (container.entryPoint != null) { + setEntrypoint(container.entryPoint) + } + if (container.ports.isNotEmpty()) { + setExposedPorts(Ports.parse(container.ports)) + } + if (container.environment.isNotEmpty()) { + setEnvironment(container.environment) + } + container.user?.let { setUser(it) } + } + +private fun BaseImageSettings.toRegistryImage(): RegistryImage { + val imageReference = ImageReference.parse(fullName) + val registryImage = RegistryImage.named(imageReference) + registryImage.configureCredentials(imageReference, credHelper, auth) + return registryImage +} + +private fun TargetImageSettings.toRegistryImages(): RegistryImage { + val imageReference = ImageReference.parse(name) + val registryImage = RegistryImage.named(imageReference) + registryImage.configureCredentials(imageReference, credHelper, auth) + return registryImage +} + +/** Env var that overrides the configured target image tags (comma-separated). */ +private const val TARGET_IMAGE_TAGS_ENV = "JIB_TARGET_IMAGE_TAGS" + +/** + * Tags to publish, honoring a `JIB_TARGET_IMAGE_TAGS` override. + * + * The toolchain pinned by `./kotlin` exposes no `--setting` flag, so environment + * variables are how release CI customizes the published tags, e.g. + * `JIB_TARGET_IMAGE_TAGS=1.2.4,,latest`. When unset or blank, the configured + * [TargetImageSettings.tags] are used. + */ +private fun TargetImageSettings.effectiveTags(): List = + System.getenv(TARGET_IMAGE_TAGS_ENV) + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + ?: tags + +private fun RegistryImage.configureCredentials( + imageReference: ImageReference, + credHelper: String?, + auth: Credentials?, +) { + val credentialRetrieverFactory = CredentialRetrieverFactory.forImage(imageReference) { logEvent -> + println("${logEvent.level} ${logEvent.message}") + } + addCredentialRetriever(credentialRetrieverFactory.dockerConfig()) + addCredentialRetriever(credentialRetrieverFactory.wellKnownCredentialHelpers()) + if (credHelper != null) { + addCredentialRetriever(credentialRetrieverFactory.dockerCredentialHelper(credHelper)) + } + if (auth != null) { + val basicAuth = credentialRetrieverFactory.known(Credential.from(auth.username, auth.password), "basic auth") + addCredentialRetriever(basicAuth) + } +} diff --git a/plugins/jib/src/settings.kt b/plugins/jib/src/settings.kt new file mode 100644 index 0000000..48bb43c --- /dev/null +++ b/plugins/jib/src/settings.kt @@ -0,0 +1,106 @@ +package io.sdkman.kotlintoolchain.plugins.jib + +import org.jetbrains.amper.plugins.Configurable + +@Configurable +interface JibSettings { + val container: ContainerSettings + val baseImage: BaseImageSettings + val targetImage: TargetImageSettings +} + +@Configurable +interface ContainerSettings { + /** + * The main class of the application to run. + */ + val mainClass: String + + /** + * JVM arguments passed to the `java` command in the default entrypoint of the container to start the application. + */ + val jvmArgs: List + get() = emptyList() + + /** + * When specified, overrides the default entrypoint of the base image. + * The default entrypoint is the `java` command with the runtime classpath, [jvmArgs] and [mainClass]. + */ + val entryPoint: List? + + /** + * Ports the container exposes at runtime (sets `EXPOSE` entries on the image). Each entry is either a bare port + * number (e.g. `"8080"`, defaults to TCP) or `"/"` (e.g. `"8080/tcp"`, `"53/udp"`). + */ + val ports: List + get() = emptyList() + + /** + * Environment variables baked into the image as defaults; can be overridden at container runtime. + */ + val environment: Map + get() = emptyMap() + + /** + * The user (and optionally group) the container runs as. May be a name or numeric id, with optional group + * (e.g. `"1000"` or `"nobody:nogroup"`). + */ + val user: String? +} + +@Configurable +interface BaseImageSettings { + /** + * The full name of the image, including the registry and tag. + */ + val fullName: String + + /** + * The username/password authentication used to pull the image from a private registry. + * + * Cannot be provided together with [credHelper]. + */ + val auth: Credentials? + + /** + * The name of the Docker Credential Helper to use to authenticate when pulling the image from a private registry. + * + * Cannot be provided together with [auth]. + */ + val credHelper: String? +} + +@Configurable +interface TargetImageSettings { + /** + * The name of the image, including the registry. The tag can be omitted, and several tags can be provided instead + * using the [tags] list. + */ + val name: String + + /** + * The username/password authentication used to push the image to a registry. + * + * Cannot be provided together with [credHelper]. + */ + val auth: Credentials? + + /** + * The name of the Docker Credential Helper to use to authenticate when pushing the image to a Docker registry. + * + * Cannot be provided together with [auth]. + */ + val credHelper: String? + + /** + * The tags to apply to the image. + */ + val tags: List + get() = emptyList() +} + +@Configurable +interface Credentials { + val username: String + val password: String +} diff --git a/plugins/ktlint/module.yaml b/plugins/ktlint/module.yaml new file mode 100644 index 0000000..899876b --- /dev/null +++ b/plugins/ktlint/module.yaml @@ -0,0 +1,15 @@ +product: jvm/amper-plugin + +description: A plugin to run the ktlint linter and formatter. + +pluginInfo: + settingsClass: io.sdkman.kotlintoolchain.plugins.ktlint.Settings + +# No hard dependency on ktlint itself — resolved dynamically via Classpath in plugin.yaml. + +settings: + jvm: + jdk: + version: 21 + kotlin: + languageVersion: 2.1 diff --git a/plugins/ktlint/plugin.yaml b/plugins/ktlint/plugin.yaml new file mode 100644 index 0000000..9b42ea4 --- /dev/null +++ b/plugins/ktlint/plugin.yaml @@ -0,0 +1,23 @@ +tasks: + check: + action: !io.sdkman.kotlintoolchain.plugins.ktlint.runKtlintCheck + commonParameters: + settings: ${pluginSettings} + sources: ${module.kotlinJavaSources} + ktlintClasspath: + - $libs.ktlint.cli + rulesetClasspath: ${pluginSettings.rulesetClasspath} + outputReport: ${taskOutputDir}/ktlint-report.xml + + format: + action: !io.sdkman.kotlintoolchain.plugins.ktlint.runKtlintFormat + commonParameters: ${check.action.commonParameters} + outputReport: ${taskOutputDir}/ktlint-format-report.xml + +checks: + - performedBy: check + name: ktlint + +commands: + - name: ktlintFormat + performedBy: format diff --git a/plugins/ktlint/src/CommonKtlintSettings.kt b/plugins/ktlint/src/CommonKtlintSettings.kt new file mode 100644 index 0000000..bb57aee --- /dev/null +++ b/plugins/ktlint/src/CommonKtlintSettings.kt @@ -0,0 +1,13 @@ +package io.sdkman.kotlintoolchain.plugins.ktlint + +import org.jetbrains.amper.plugins.Classpath +import org.jetbrains.amper.plugins.Configurable +import org.jetbrains.amper.plugins.ModuleSources + +@Configurable +interface CommonKtlintSettings { + val settings: Settings + val sources: ModuleSources + val ktlintClasspath: Classpath + val rulesetClasspath: Classpath? +} diff --git a/plugins/ktlint/src/Settings.kt b/plugins/ktlint/src/Settings.kt new file mode 100644 index 0000000..c63d626 --- /dev/null +++ b/plugins/ktlint/src/Settings.kt @@ -0,0 +1,20 @@ +package io.sdkman.kotlintoolchain.plugins.ktlint + +import org.jetbrains.amper.plugins.Classpath +import org.jetbrains.amper.plugins.Configurable +import java.nio.file.Path + +@Configurable +interface Settings { + /** + * Optional path to an `.editorconfig` file used as a fallback for properties not defined in any `.editorconfig` + * on the path of a source file. + */ + val editorConfigPath: Path? + + /** + * Extra rule-set jars to load into ktlint via `--ruleset`. Each entry is either a Maven coordinate + * (e.g. `com.example:my-ktlint-rules:1.0.0`) or a catalog reference (e.g. `$libs.ktlint.rules`). + */ + val rulesetClasspath: Classpath? +} diff --git a/plugins/ktlint/src/runKtlint.kt b/plugins/ktlint/src/runKtlint.kt new file mode 100644 index 0000000..b024e14 --- /dev/null +++ b/plugins/ktlint/src/runKtlint.kt @@ -0,0 +1,90 @@ +package io.sdkman.kotlintoolchain.plugins.ktlint + +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.Output +import org.jetbrains.amper.plugins.TaskAction +import java.io.File +import java.nio.file.Path +import kotlin.concurrent.thread +import kotlin.io.path.createParentDirectories +import kotlin.io.path.isDirectory +import kotlin.io.path.pathString +import kotlin.io.path.writeText + +@TaskAction +fun runKtlintCheck( + @Input commonParameters: CommonKtlintSettings, + @Output outputReport: Path, +) = runKtlint( + commonParameters = commonParameters, + outputReport = outputReport, + format = false, +) + +@TaskAction +fun runKtlintFormat( + @Input commonParameters: CommonKtlintSettings, + @Output outputReport: Path, +) = runKtlint( + commonParameters = commonParameters, + outputReport = outputReport, + format = true, +) + +private fun runKtlint( + commonParameters: CommonKtlintSettings, + outputReport: Path, + format: Boolean, +) { + val sourceDirs = commonParameters.sources.sourceDirectories.filter { it.isDirectory() } + if (sourceDirs.isEmpty()) { + outputReport.createParentDirectories() + outputReport.writeText("") + return + } + + outputReport.createParentDirectories() + + val args = mutableListOf().apply { + add(KTLINT_MAIN_CLASS) + if (format) { + add("--format") + } + commonParameters.settings.editorConfigPath?.let { + add("--editorconfig=${it.pathString}") + } + commonParameters.rulesetClasspath?.resolvedFiles?.forEach { + add("--ruleset=${it.pathString}") + } + add("--reporter=plain") + add("--reporter=checkstyle,output=${outputReport.pathString}") + sourceDirs.forEach { dir -> + add("${dir.pathString}${File.separator}**${File.separator}*.kt") + } + } + + val ktlintCp = commonParameters.ktlintClasspath.resolvedFiles.joinToString(File.pathSeparator) + val commandLine = buildList { + add(ProcessHandle.current().info().command().orElse("java")) + // ktlint reaches into JDK internals via reflection on modern JREs. + add("--add-opens=java.base/java.lang=ALL-UNNAMED") + add("-cp") + add(ktlintCp) + addAll(args) + } + + val process = ProcessBuilder(commandLine) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .start() + + val capture = thread { process.inputStream.copyTo(System.out) } + val exitCode = process.waitFor() + capture.join() + + check(exitCode == 0) { + "ktlint terminated with code = $exitCode. See the log above for details." + } +} + +private const val KTLINT_MAIN_CLASS = "com.pinterest.ktlint.Main" diff --git a/plugins/package/module.yaml b/plugins/package/module.yaml new file mode 100644 index 0000000..687c3bd --- /dev/null +++ b/plugins/package/module.yaml @@ -0,0 +1,13 @@ +product: jvm/amper-plugin + +description: Stages the application JAR at build/libs/${module.name}.jar for CI artifact uploads. + +pluginInfo: + settingsClass: io.sdkman.kotlintoolchain.plugins.pkg.Settings + +settings: + jvm: + jdk: + version: 21 + kotlin: + languageVersion: 2.1 diff --git a/plugins/package/plugin.yaml b/plugins/package/plugin.yaml new file mode 100644 index 0000000..083fdbe --- /dev/null +++ b/plugins/package/plugin.yaml @@ -0,0 +1,11 @@ +tasks: + package: + action: !io.sdkman.kotlintoolchain.plugins.pkg.packageApplication + jar: ${module.jar} + outputDir: ${module.rootDir}/build/libs + defaultName: ${module.name} + settings: ${pluginSettings} + +commands: + - name: package + performedBy: package diff --git a/plugins/package/src/PackageApplication.kt b/plugins/package/src/PackageApplication.kt new file mode 100644 index 0000000..ebd8325 --- /dev/null +++ b/plugins/package/src/PackageApplication.kt @@ -0,0 +1,35 @@ +package io.sdkman.kotlintoolchain.plugins.pkg + +import org.jetbrains.amper.plugins.CompilationArtifact +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.Output +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path +import kotlin.io.path.copyTo +import kotlin.io.path.createParentDirectories +import kotlin.io.path.div + +/** + * Copy the module's compiled application JAR to `build/libs/.jar` at the module root, + * where `` is [Settings.artifactName] when set, otherwise [defaultName] (the module name). + * + * This mirrors the shape of Gradle's `application` plugin output so that CI artifact-upload + * workflows can publish the broker JAR from a stable, build-tool-agnostic location instead of + * the Toolchain's internal `build/tasks/__jarJvm/-jvm.jar` path. + * + * The `@Input jar: CompilationArtifact` parameter wires this task to the module's `jarJvm` + * output automatically; the `@Output outputDir: Path` is project-relative on purpose (same + * pattern as the binary-compatibility-validator plugin's `apiDump` task), so the staged file + * lives in `build/libs/` rather than under the Toolchain's per-task scratch directory. + */ +@TaskAction +fun packageApplication( + @Input jar: CompilationArtifact, + @Output outputDir: Path, + defaultName: String, + settings: Settings, +) { + val artifactName = settings.artifactName?.takeIf { it.isNotBlank() } ?: defaultName + val outputJar = (outputDir / "$artifactName.jar").createParentDirectories() + jar.artifact.copyTo(outputJar, overwrite = true) +} diff --git a/plugins/package/src/Settings.kt b/plugins/package/src/Settings.kt new file mode 100644 index 0000000..ba6590c --- /dev/null +++ b/plugins/package/src/Settings.kt @@ -0,0 +1,14 @@ +package io.sdkman.kotlintoolchain.plugins.pkg + +import org.jetbrains.amper.plugins.Configurable + +@Configurable +interface Settings { + /** + * Optional override for the file name of the staged JAR (no extension). + * + * Defaults to the module name. For instance, the module `sdkman-broker-2` produces + * `build/libs/sdkman-broker-2.jar` unless overridden. + */ + val artifactName: String? +} diff --git a/plugins/release/module.yaml b/plugins/release/module.yaml new file mode 100644 index 0000000..aa8a120 --- /dev/null +++ b/plugins/release/module.yaml @@ -0,0 +1,24 @@ +product: jvm/amper-plugin + +dependencies: + - $libs.jgit + - $libs.jgit.ssh.apache + # Apache MINA SSHD (pulled in by jgit.ssh.apache) declares BouncyCastle as + # optional at compile time but reaches for it at runtime to build its random + # factory; without it any SSH push fails with NoClassDefFoundError before a + # single byte hits the wire. The BouncyCastle version pin lives in the + # `bouncycastle` entry of `gradle/libs.versions.toml`. + - $libs.bouncycastle.bcprov: runtime-only + - $libs.bouncycastle.bcpkix: runtime-only + - $libs.slf4j.nop + +pluginInfo: + id: release + settingsClass: io.sdkman.kotlintoolchain.plugins.release.Settings + +settings: + jvm: + jdk: + version: 21 + kotlin: + languageVersion: 2.1 diff --git a/plugins/release/plugin.yaml b/plugins/release/plugin.yaml new file mode 100644 index 0000000..099ec67 --- /dev/null +++ b/plugins/release/plugin.yaml @@ -0,0 +1,55 @@ +tasks: + currentVersion: + action: !io.sdkman.kotlintoolchain.plugins.release.tasks.currentVersion + moduleRootDir: ${module.rootDir} + settings: ${pluginSettings} + + writeVersion: + action: !io.sdkman.kotlintoolchain.plugins.release.tasks.writeVersion + moduleRootDir: ${module.rootDir} + outputDir: ${taskOutputDir} + settings: ${pluginSettings} + + writeReleaseProperties: + action: !io.sdkman.kotlintoolchain.plugins.release.tasks.writeReleaseProperties + moduleRootDir: ${module.rootDir} + outputDir: ${taskOutputDir} + settings: ${pluginSettings} + + verifyRelease: + action: !io.sdkman.kotlintoolchain.plugins.release.tasks.verifyRelease + moduleRootDir: ${module.rootDir} + settings: ${pluginSettings} + + createRelease: + action: !io.sdkman.kotlintoolchain.plugins.release.tasks.createRelease + moduleRootDir: ${module.rootDir} + settings: ${pluginSettings} + + pushRelease: + action: !io.sdkman.kotlintoolchain.plugins.release.tasks.pushRelease + moduleRootDir: ${module.rootDir} + settings: ${pluginSettings} + + release: + action: !io.sdkman.kotlintoolchain.plugins.release.tasks.release + moduleRootDir: ${module.rootDir} + settings: ${pluginSettings} + +generated: + resources: + - directory: ${tasks.writeVersion.action.outputDir} + - directory: ${tasks.writeReleaseProperties.action.outputDir} + +# Public entry points. Tasks above are an implementation detail; users +# invoke the plugin through these commands via `./kotlin do `. +# `writeVersion` and `writeReleaseProperties` are intentionally not exposed: +# they are build-graph contributors (their `@Output`s are wired into +# `generated.resources`) and run automatically when something downstream needs +# the generated files. +commands: + - currentVersion + - verifyRelease + - createRelease + - pushRelease + - release diff --git a/plugins/release/src/Settings.kt b/plugins/release/src/Settings.kt new file mode 100644 index 0000000..b7f6050 --- /dev/null +++ b/plugins/release/src/Settings.kt @@ -0,0 +1,64 @@ +package io.sdkman.kotlintoolchain.plugins.release + +import org.jetbrains.amper.plugins.Configurable + +/** + * Configuration for the release plugin. + * + * MVP port of axion-release-plugin's `scmVersion { ... }` DSL, scoped down to + * what the 5 release tasks need. See README.md for the axion mapping. + */ +@Configurable +interface Settings { + /** + * Path to the Git repository, relative to the module root, or absolute. + * + * When empty, the plugin ascends from the module root looking for a `.git` + * directory. This handles the typical "monorepo with one Git repo at the + * project root" layout without forcing every module to spell it out. + */ + val repoDir: String get() = "" + + /** Tag prefix used to identify release tags. Default `v`, e.g. `v1.2.3`. */ + val tagPrefix: String get() = "v" + + /** + * Optional separator between the prefix and the version number. + * + * Empty by default, so a tag looks like `v1.2.3`. Set to `-` to get + * `v-1.2.3`, mirroring axion's `tag.versionSeparator`. + */ + val versionSeparator: String get() = "" + + /** Version returned when no release tags exist yet. */ + val initialVersion: String get() = "0.1.0" + + /** + * Skip the uncommitted-changes pre-release check. + * + * Equivalent to axion's `ignoreUncommittedChanges = true`. + */ + val ignoreUncommittedChanges: Boolean get() = false + + /** + * Regex matched against the current branch name. Releases on + * non-matching branches fail unless `RELEASE_DISABLE_CHECKS=true`. + * + * Empty disables the gate (any branch may release). Default: + * `main|master`. + */ + val releaseBranchPattern: String get() = "main|master" + + /** Pre-release check toggles. */ + val checks: ChecksSettings +} + +/** Toggles for individual pre-release checks. */ +@Configurable +interface ChecksSettings { + /** Fail the release if the working tree has staged or unstaged changes. */ + val uncommittedChanges: Boolean get() = true + + /** Fail the release if local has unpushed commits or remote has unpulled commits. */ + val aheadOfRemote: Boolean get() = true +} diff --git a/plugins/release/src/checks/Checks.kt b/plugins/release/src/checks/Checks.kt new file mode 100644 index 0000000..dd9f7dc --- /dev/null +++ b/plugins/release/src/checks/Checks.kt @@ -0,0 +1,75 @@ +package io.sdkman.kotlintoolchain.plugins.release.checks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline + +/** + * Pre-release checks (axion's `verifyRelease`). + * + * MVP supports the two cheap ones: working-tree-clean and not-ahead-of-remote. + * The snapshot-dependency check from axion is out of MVP — Toolchain doesn't + * surface resolved dependency coordinates to plugins yet without significant + * extra wiring. + */ +class ReleaseChecks( + private val settings: Settings, + private val pipeline: VersionPipeline, + private val env: Map = System.getenv(), +) { + /** + * Run all configured checks. Returns the list of failure messages — empty + * means everything passed. Callers convert non-empty into `error(...)`. + */ + fun run(repo: GitRepo): List { + if (env["RELEASE_DISABLE_CHECKS"].asBoolean()) return emptyList() + + val failures = mutableListOf() + + if (settings.checks.uncommittedChanges && + !settings.ignoreUncommittedChanges && + !env["RELEASE_DISABLE_UNCOMMITTED_CHECK"].asBoolean() + ) { + if (repo.isDirty()) { + failures += "Working tree has uncommitted changes. " + + "Commit or stash them, set ignoreUncommittedChanges=true in module.yaml, " + + "or run with RELEASE_DISABLE_UNCOMMITTED_CHECK=true." + } + } + + if (settings.checks.aheadOfRemote && + !env["RELEASE_DISABLE_REMOTE_CHECK"].asBoolean() + ) { + val ab = repo.aheadBehind() + when { + ab == null -> { + if (!repo.isDetached()) { + failures += "Current branch '${repo.branchName()}' has no configured upstream. " + + "Set one (`git push -u`) or run with RELEASE_DISABLE_REMOTE_CHECK=true." + } + } + !ab.isInSync -> { + failures += "Branch is out of sync with its upstream " + + "(ahead=${ab.ahead}, behind=${ab.behind}). " + + "Pull/push to align, or run with RELEASE_DISABLE_REMOTE_CHECK=true." + } + } + } + + val branchPattern = settings.releaseBranchPattern + if (branchPattern.isNotBlank()) { + val branch = pipeline.effectiveBranchName(repo) + if (branch.isNotBlank() && !Regex(branchPattern).matches(branch)) { + failures += "Releases are gated to branches matching '$branchPattern'; " + + "current branch is '$branch'. " + + "Override the branch with RELEASE_OVERRIDDEN_BRANCH_NAME, " + + "loosen releaseBranchPattern, or run with RELEASE_DISABLE_CHECKS=true." + } + } + + return failures + } +} + +private fun String?.asBoolean(): Boolean = + this != null && this.equals("true", ignoreCase = true) diff --git a/plugins/release/src/git/Repo.kt b/plugins/release/src/git/Repo.kt new file mode 100644 index 0000000..add3041 --- /dev/null +++ b/plugins/release/src/git/Repo.kt @@ -0,0 +1,220 @@ +package io.sdkman.kotlintoolchain.plugins.release.git + +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.BranchTrackingStatus +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.ObjectId +import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.revwalk.RevWalk +import org.eclipse.jgit.transport.RefSpec +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider +import java.nio.file.Files +import java.nio.file.Path + +/** + * Information about a Git tag relevant to release versioning. + * + * @property name full tag name, e.g. `v1.2.3` + * @property versionString the tag name with the configured prefix (and optional + * separator) stripped, e.g. `1.2.3` + * @property commitId the commit the tag points at (peeled if annotated) + */ +data class TagInfo(val name: String, val versionString: String, val commitId: ObjectId) + +/** Result of walking tags up from HEAD. */ +sealed interface NearestTag { + /** No release tag found anywhere in the history reachable from HEAD. */ + data object None : NearestTag + + /** A tag was found. `onHead = true` when HEAD points exactly at the tagged commit. */ + data class Found(val tag: TagInfo, val onHead: Boolean) : NearestTag +} + +/** Counts of unpushed local / unpulled remote commits relative to the configured upstream. */ +data class AheadBehind(val ahead: Int, val behind: Int) { + val isInSync: Boolean get() = ahead == 0 && behind == 0 +} + +/** + * Thin JGit wrapper exposing only what the release plugin needs. + * + * Always close (or use [use]) so the underlying repository is released. + */ +class GitRepo private constructor(private val git: Git) : AutoCloseable { + private val repository: Repository get() = git.repository + + /** + * Branch name. When the working tree is on a detached HEAD (typical on + * CI checkouts), JGit returns the abbreviated commit SHA, which is rarely + * what callers want — they should consult [isDetached] and supply an + * override (axion's `RELEASE_OVERRIDDEN_BRANCH_NAME`). + */ + fun branchName(): String = repository.branch ?: "" + + fun isDetached(): Boolean { + val full = repository.fullBranch ?: return true + return !full.startsWith(Constants.R_HEADS) + } + + fun isDirty(): Boolean { + val status = git.status().call() + return status.hasUncommittedChanges() || status.untracked.isNotEmpty() + } + + /** + * Compute ahead/behind versus the upstream of the current branch. + * + * Returns `null` when the current branch has no configured upstream + * (or when on detached HEAD) — callers decide whether that should fail + * the release or be silently treated as "in sync". + */ + fun aheadBehind(): AheadBehind? { + val branch = repository.branch ?: return null + val tracking = BranchTrackingStatus.of(repository, branch) ?: return null + return AheadBehind(tracking.aheadCount, tracking.behindCount) + } + + /** + * Walk tags from HEAD using the "first tag encountered walking up" rule + * (axion's default; not the highest-version-in-tree variant). Returns + * [NearestTag.None] when no tags match the prefix anywhere in the history + * reachable from HEAD. + */ + fun nearestTagFromHead(prefix: String, separator: String): NearestTag { + val headId = repository.resolve(Constants.HEAD) ?: return NearestTag.None + val tagsByCommit = listMatchingTags(prefix, separator).groupBy { it.commitId } + + if (tagsByCommit.isEmpty()) return NearestTag.None + + RevWalk(repository).use { walk -> + walk.markStart(walk.parseCommit(headId)) + for (commit in walk) { + val tags = tagsByCommit[commit.id] ?: continue + val best = tags.maxByOrNull { it.versionString } ?: continue + return NearestTag.Found(best, onHead = commit.id == headId) + } + } + return NearestTag.None + } + + /** + * List release tags whose name matches the configured prefix. + * + * For lightweight tags `Ref.objectId` is the commit; for annotated tags + * we have to peel through the tag object first. + */ + fun listMatchingTags(prefix: String, separator: String): List { + val expected = prefix + separator + return git.tagList().call().mapNotNull { ref -> ref.toTagInfo(expected) } + } + + private fun Ref.toTagInfo(expectedPrefix: String): TagInfo? { + val shortName = name.removePrefix(Constants.R_TAGS) + if (!shortName.startsWith(expectedPrefix)) return null + val version = shortName.substring(expectedPrefix.length) + val commitId = peeledCommitId(this) ?: return null + return TagInfo(name = shortName, versionString = version, commitId = commitId) + } + + private fun peeledCommitId(ref: Ref): ObjectId? { + val peeled = repository.refDatabase.peel(ref) + return peeled.peeledObjectId ?: peeled.objectId + } + + fun headCommit(): RevCommit { + val id = repository.resolve(Constants.HEAD) ?: error("Repository has no HEAD") + return RevWalk(repository).use { it.parseCommit(id) } + } + + /** + * Tags whose name matches [prefix]+[separator] and that point exactly at + * HEAD. Used by `pushRelease` to discover what `createRelease` just made. + */ + fun matchingTagsAtHead(prefix: String, separator: String): List { + val headId = repository.resolve(Constants.HEAD) ?: return emptyList() + return listMatchingTags(prefix, separator).filter { it.commitId == headId } + } + + /** Create an annotated tag at HEAD. Fails if a tag of the same name already exists. */ + fun createAnnotatedTag(name: String, message: String) { + git.tag() + .setName(name) + .setMessage(message) + .setAnnotated(true) + .call() + } + + /** Look up an existing tag reference by short name (e.g. `v1.2.3`). */ + fun findTag(name: String): Ref? = repository.refDatabase.findRef(Constants.R_TAGS + name) + + /** + * Push a single tag to the configured `origin`. Honors HTTPS credentials + * supplied via env (`GIT_USERNAME`/`GIT_PASSWORD`, or `GITHUB_TOKEN`), + * otherwise falls back to JGit defaults (SSH agent, `~/.ssh/config`). + * + * Always pushes only the tag itself — branches/commits are out of scope + * (axion's `pushTagsOnly` is the default here). + */ + fun pushTag(name: String, remote: String = Constants.DEFAULT_REMOTE_NAME) { + val refSpec = RefSpec("${Constants.R_TAGS}$name:${Constants.R_TAGS}$name") + val push = git.push() + .setRemote(remote) + .setRefSpecs(refSpec) + credentialsFromEnv()?.let { push.setCredentialsProvider(it) } + push.call().forEach { result -> + result.remoteUpdates.forEach { update -> + val ok = update.status == org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK || + update.status == org.eclipse.jgit.transport.RemoteRefUpdate.Status.UP_TO_DATE + if (!ok) error("Push of $name failed: ${update.status} ${update.message ?: ""}") + } + } + } + + private fun credentialsFromEnv(): UsernamePasswordCredentialsProvider? { + val token = System.getenv("GITHUB_TOKEN") + if (!token.isNullOrBlank()) { + // GitHub: any non-empty username is accepted when using a token as the password. + return UsernamePasswordCredentialsProvider("x-access-token", token) + } + val user = System.getenv("GIT_USERNAME") + val pass = System.getenv("GIT_PASSWORD") + if (!user.isNullOrBlank() && !pass.isNullOrBlank()) { + return UsernamePasswordCredentialsProvider(user, pass) + } + return null + } + + override fun close() = git.close() + + companion object { + /** + * Open the Git repository associated with [start]. When [start] is + * empty/null, we ascend from [moduleRoot] looking for a `.git` + * directory. Mirrors how a developer would invoke `git` from inside + * any subdirectory of the repo. + */ + fun open(moduleRoot: Path, configuredRepoDir: String): GitRepo { + val explicit = configuredRepoDir.takeIf { it.isNotBlank() }?.let { + val p = java.nio.file.Paths.get(it) + if (p.isAbsolute) p else moduleRoot.resolve(p).normalize() + } + val anchor = explicit ?: moduleRoot + val gitDir = ascendForGitDir(anchor) + ?: error("No Git repository found at or above $anchor") + val git = Git.open(gitDir.toFile()) + return GitRepo(git) + } + + private fun ascendForGitDir(start: Path): Path? { + var current: Path? = start.toAbsolutePath().normalize() + while (current != null) { + val candidate = current.resolve(".git") + if (Files.isDirectory(candidate) || Files.isRegularFile(candidate)) return current + current = current.parent + } + return null + } + } +} diff --git a/plugins/release/src/tasks/CreateRelease.kt b/plugins/release/src/tasks/CreateRelease.kt new file mode 100644 index 0000000..39cc2ae --- /dev/null +++ b/plugins/release/src/tasks/CreateRelease.kt @@ -0,0 +1,26 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path + +/** + * Run pre-release hooks (checks) and create the release tag locally. + * + * Mirrors axion's `createRelease`: never pushes. Use [pushRelease] or the + * combined [release] task to also push. + */ +@TaskAction +fun createRelease( + @Input(inferTaskDependency = false) moduleRootDir: Path, + settings: Settings, +) { + val pipeline = VersionPipeline(settings) + GitRepo.open(moduleRootDir, settings.repoDir).use { repo -> + verifyOrFail(repo, settings, pipeline) + createReleaseTag(repo, settings, pipeline) + } +} diff --git a/plugins/release/src/tasks/CurrentVersion.kt b/plugins/release/src/tasks/CurrentVersion.kt new file mode 100644 index 0000000..4584099 --- /dev/null +++ b/plugins/release/src/tasks/CurrentVersion.kt @@ -0,0 +1,28 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path + +/** + * Print the version that the release plugin would resolve right now. + * + * Mirrors axion's `currentVersion` (alias `cV`) task. + * + * No `@Output`s declared — the task always re-runs, which is what we want + * because version derivation depends on Git state Toolchain can't checksum. + */ +@TaskAction +fun currentVersion( + @Input(inferTaskDependency = false) moduleRootDir: Path, + settings: Settings, +) { + val pipeline = VersionPipeline(settings) + GitRepo.open(moduleRootDir, settings.repoDir).use { repo -> + val inferred = pipeline.infer(repo) + println(inferred.version) + } +} diff --git a/plugins/release/src/tasks/PushRelease.kt b/plugins/release/src/tasks/PushRelease.kt new file mode 100644 index 0000000..81bfc24 --- /dev/null +++ b/plugins/release/src/tasks/PushRelease.kt @@ -0,0 +1,66 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.SemVer +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path + +/** + * Push the release tag at HEAD to `origin`. + * + * Mirrors axion's `pushRelease`. When `RELEASE_FORCE_VERSION` is set, push + * that specific tag instead of relying on HEAD being tagged. Otherwise, the + * task expects [createRelease] (or a hand-run `git tag`) to have placed a + * matching tag at HEAD. + * + * Authentication is picked up from env: `GITHUB_TOKEN`, or `GIT_USERNAME` + + * `GIT_PASSWORD`. SSH falls back to JGit defaults (system agent, ssh config). + */ +@TaskAction +fun pushRelease( + @Input(inferTaskDependency = false) moduleRootDir: Path, + settings: Settings, +) { + val pipeline = VersionPipeline(settings) + GitRepo.open(moduleRootDir, settings.repoDir).use { repo -> + val tagName = pickTagToPush(repo, settings, pipeline) + pushReleaseTag(repo, tagName) + } +} + +/** Pick which tag to push, or fail with an explanation. */ +internal fun pickTagToPush( + repo: GitRepo, + settings: Settings, + pipeline: VersionPipeline, + env: Map = System.getenv(), +): String { + val forced = env["RELEASE_FORCE_VERSION"]?.takeIf { it.isNotBlank() } + if (forced != null) { + val parsed = SemVer.parseOrNull(forced) + ?: error("RELEASE_FORCE_VERSION='$forced' is not MAJOR.MINOR.PATCH") + val tagName = pipeline.tagNameFor(parsed) + if (repo.findTag(tagName) == null) { + error("RELEASE_FORCE_VERSION asks to push '$tagName' but no such tag exists locally.") + } + return tagName + } + val atHead = repo.matchingTagsAtHead(settings.tagPrefix, settings.versionSeparator) + return when { + atHead.isEmpty() -> error( + "No release tag at HEAD to push. Run createRelease first, or set " + + "RELEASE_FORCE_VERSION=X to push tag '${settings.tagPrefix}${settings.versionSeparator}X'.", + ) + atHead.size == 1 -> atHead.single().name + else -> { + // Multiple matching tags at HEAD — push the highest by SemVer to be deterministic. + atHead.mapNotNull { tag -> + SemVer.parseOrNull(tag.versionString)?.let { sv -> sv to tag.name } + }.maxByOrNull { it.first }?.second + ?: error("Multiple matching tags at HEAD but none parse as SemVer: ${atHead.map { it.name }}") + } + } +} diff --git a/plugins/release/src/tasks/Release.kt b/plugins/release/src/tasks/Release.kt new file mode 100644 index 0000000..3ca3b8e --- /dev/null +++ b/plugins/release/src/tasks/Release.kt @@ -0,0 +1,28 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path + +/** + * Atomic combo: pre-release checks → create the release tag locally → push it. + * + * Mirrors axion's `release` (the recommended day-to-day entry point). All + * three steps run in-process with the same `GitRepo` handle, so there's no + * window where another process could see a tag that the plugin then aborts. + */ +@TaskAction +fun release( + @Input(inferTaskDependency = false) moduleRootDir: Path, + settings: Settings, +) { + val pipeline = VersionPipeline(settings) + GitRepo.open(moduleRootDir, settings.repoDir).use { repo -> + verifyOrFail(repo, settings, pipeline) + val tagName = createReleaseTag(repo, settings, pipeline) + pushReleaseTag(repo, tagName) + } +} diff --git a/plugins/release/src/tasks/ReleaseSteps.kt b/plugins/release/src/tasks/ReleaseSteps.kt new file mode 100644 index 0000000..a4b5f4f --- /dev/null +++ b/plugins/release/src/tasks/ReleaseSteps.kt @@ -0,0 +1,56 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.checks.ReleaseChecks +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.SemVer +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline + +/** + * Internal helpers shared by [createRelease], [pushRelease], and [release]. + * + * Top-level non-`@TaskAction` Kotlin functions are fine inside a plugin + * module — only functions exposed as task actions need the marker. + */ + +/** Run pre-release checks against [repo]; throw on any failure. */ +internal fun verifyOrFail(repo: GitRepo, settings: Settings, pipeline: VersionPipeline) { + val failures = ReleaseChecks(settings, pipeline).run(repo) + if (failures.isNotEmpty()) { + error(failures.joinToString(prefix = "Pre-release checks failed:\n - ", separator = "\n - ")) + } +} + +/** + * Determine the next release version and create an annotated tag at HEAD + * for it. No push. + * + * Returns the tag short name (e.g. `v1.2.3`). + * + * Throws when HEAD is already on a release tag (nothing to release) — callers + * decide whether to swallow that or surface it. + */ +internal fun createReleaseTag( + repo: GitRepo, + settings: Settings, + pipeline: VersionPipeline, +): String { + val next: SemVer = pipeline.nextReleaseVersion(repo) + ?: error( + "HEAD is already on a release tag. Make a new commit (or use " + + "RELEASE_FORCE_VERSION) before running createRelease/release.", + ) + val tagName = pipeline.tagNameFor(next) + if (repo.findTag(tagName) != null) { + error("Tag '$tagName' already exists. Delete it (`git tag -d $tagName`) or bump past it before retrying.") + } + repo.createAnnotatedTag(name = tagName, message = "Release $next") + println("Created release tag $tagName at HEAD (${repo.headCommit().abbreviate(7).name()}).") + return tagName +} + +/** Push the named tag to `origin`. */ +internal fun pushReleaseTag(repo: GitRepo, tagName: String) { + repo.pushTag(tagName) + println("Pushed tag $tagName to origin.") +} diff --git a/plugins/release/src/tasks/VerifyRelease.kt b/plugins/release/src/tasks/VerifyRelease.kt new file mode 100644 index 0000000..a6c926d --- /dev/null +++ b/plugins/release/src/tasks/VerifyRelease.kt @@ -0,0 +1,31 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.checks.ReleaseChecks +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path + +/** + * Run all configured pre-release checks and fail the task on any violation. + * + * Mirrors axion's `verifyRelease`. `release` and `createRelease` invoke the + * same checks in-process before doing their side effects. + */ +@TaskAction +fun verifyRelease( + @Input(inferTaskDependency = false) moduleRootDir: Path, + settings: Settings, +) { + val pipeline = VersionPipeline(settings) + val checks = ReleaseChecks(settings, pipeline) + GitRepo.open(moduleRootDir, settings.repoDir).use { repo -> + val failures = checks.run(repo) + if (failures.isNotEmpty()) { + error(failures.joinToString(prefix = "Pre-release checks failed:\n - ", separator = "\n - ")) + } + println("Pre-release checks passed.") + } +} diff --git a/plugins/release/src/tasks/WriteReleaseProperties.kt b/plugins/release/src/tasks/WriteReleaseProperties.kt new file mode 100644 index 0000000..6d8ade1 --- /dev/null +++ b/plugins/release/src/tasks/WriteReleaseProperties.kt @@ -0,0 +1,43 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import org.jetbrains.amper.plugins.ExecutionAvoidance +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.Output +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createParentDirectories +import kotlin.io.path.deleteRecursively +import kotlin.io.path.div +import kotlin.io.path.writeText + +/** + * Write the inferred version to a `release.properties` file at the root of [outputDir], as + * a single `release=` line. + * + * This is a convenience companion to [writeVersion] for consumers that already read the + * classpath resource `release.properties` with `java.util.Properties.load()` (legacy + * sdkman-broker shape). New code should prefer `META-INF/release/version.txt` produced by + * [writeVersion]; this task is purely an in-house compatibility shim. + * + * Execution avoidance is disabled because version derivation has hidden inputs (Git history) + * that Toolchain has no way to fingerprint. + */ +@OptIn(ExperimentalPathApi::class) +@TaskAction(executionAvoidance = ExecutionAvoidance.Disabled) +fun writeReleaseProperties( + @Input(inferTaskDependency = false) moduleRootDir: Path, + @Output outputDir: Path, + settings: Settings, +) { + val pipeline = VersionPipeline(settings) + val inferred = GitRepo.open(moduleRootDir, settings.repoDir).use { pipeline.infer(it) } + + outputDir.deleteRecursively() + val propertiesFile = outputDir / "release.properties" + propertiesFile.createParentDirectories() + propertiesFile.writeText("release=${inferred.version}\n") +} diff --git a/plugins/release/src/tasks/WriteVersion.kt b/plugins/release/src/tasks/WriteVersion.kt new file mode 100644 index 0000000..22f66e9 --- /dev/null +++ b/plugins/release/src/tasks/WriteVersion.kt @@ -0,0 +1,49 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import org.jetbrains.amper.plugins.ExecutionAvoidance +import org.jetbrains.amper.plugins.Input +import org.jetbrains.amper.plugins.Output +import org.jetbrains.amper.plugins.TaskAction +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createParentDirectories +import kotlin.io.path.deleteRecursively +import kotlin.io.path.div +import kotlin.io.path.writeText + +/** + * Write the inferred version to `META-INF/release/version.txt` inside [outputDir]. + * + * This is the canonical "publish the version" task. Downstream consumers + * pick the file up in one of two ways: + * + * - **Build-time** — another task action declares + * `@Input versionFile: Path` pointing at + * `${tasks.writeVersion.action.outputDir}/META-INF/release/version.txt`, + * or `@Input versionDir: Path` pointing at the whole directory. Toolchain + * auto-wires the dependency by matching `@Input` and `@Output` paths. + * - **Runtime** — `plugin.yaml` registers [outputDir] under + * `generated.resources`, so the file ships in the JAR classpath. Apps + * read it via `getResourceAsStream("/META-INF/release/version.txt")`. + * + * Execution avoidance is disabled because version derivation has hidden + * inputs (Git history) that Toolchain has no way to fingerprint. + */ +@OptIn(ExperimentalPathApi::class) +@TaskAction(executionAvoidance = ExecutionAvoidance.Disabled) +fun writeVersion( + @Input(inferTaskDependency = false) moduleRootDir: Path, + @Output outputDir: Path, + settings: Settings, +) { + val pipeline = VersionPipeline(settings) + val inferred = GitRepo.open(moduleRootDir, settings.repoDir).use { pipeline.infer(it) } + + outputDir.deleteRecursively() + val versionFile = outputDir / "META-INF" / "release" / "version.txt" + versionFile.createParentDirectories() + versionFile.writeText(inferred.version + "\n") +} diff --git a/plugins/release/src/version/Pipeline.kt b/plugins/release/src/version/Pipeline.kt new file mode 100644 index 0000000..832977b --- /dev/null +++ b/plugins/release/src/version/Pipeline.kt @@ -0,0 +1,181 @@ +/* + * The release/version logic in this plugin is a Kotlin port of + * allegro/axion-release-plugin (https://github.com/allegro/axion-release-plugin), + * licensed under the Apache License 2.0. + */ + +package io.sdkman.kotlintoolchain.plugins.release.version + +import io.sdkman.kotlintoolchain.plugins.release.Settings +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import io.sdkman.kotlintoolchain.plugins.release.git.NearestTag + +/** + * Where the version number originated, useful for log lines and diagnostics. + */ +enum class VersionSource { + /** Read from a Git tag, possibly bumped via `incrementPatch`. */ + Tag, + + /** No tags found; settings' `initialVersion` was used as the seed. */ + Initial, + + /** `RELEASE_FORCE_VERSION` short-circuited the pipeline. */ + ForcedVersion, +} + +/** Result of running the [VersionPipeline]. */ +data class InferredVersion( + /** Final decorated, possibly snapshot-suffixed, sanitized version string. */ + val version: String, + + /** True when the version corresponds to a release (HEAD on a tag, no SNAPSHOT). */ + val isRelease: Boolean, + + /** Where the base version came from. */ + val source: VersionSource, +) + +/** + * Implements axion's 6-phase pipeline (simplified to the MVP slice). + * + * 1. **Read** — first-tag-walking-up from HEAD ([GitRepo.nearestTagFromHead]). + * 2. **Parse** — strip the `tagPrefix`+`versionSeparator`, parse as [SemVer]. + * 3. **Increment** — when HEAD isn't exactly on the tag, `incrementPatch`. + * 4. **Decorate** — append the sanitized branch name (axion's + * `versionWithBranch`), except on `main`/`master` or a detached HEAD, where + * the version stays bare. Runs on every read, release or not. + * 5. **Append snapshot** — `-SNAPSHOT` when HEAD isn't exactly on the tag. + * 6. **Sanitize** — replace any character outside `[A-Za-z0-9._-]` with `-`. + * + * `RELEASE_FORCE_VERSION` short-circuits steps 1–3; `RELEASE_FORCE_SNAPSHOT` + * forces a snapshot suffix even on a tagged commit. + */ +class VersionPipeline( + private val settings: Settings, + private val env: Map = System.getenv(), +) { + /** Compute the displayed version string for [repo]. */ + fun infer(repo: GitRepo): InferredVersion { + val forceVersion = env["RELEASE_FORCE_VERSION"]?.takeIf { it.isNotBlank() } + val forceSnapshot = env["RELEASE_FORCE_SNAPSHOT"].asBoolean() + + if (forceVersion != null) { + val isRelease = !forceSnapshot + return InferredVersion( + version = composeVersion(forceVersion, repo, isRelease), + isRelease = isRelease, + source = VersionSource.ForcedVersion, + ) + } + + return when (val nearest = repo.nearestTagFromHead(settings.tagPrefix, settings.versionSeparator)) { + NearestTag.None -> { + val seed = SemVer.parseOrNull(settings.initialVersion) + ?: error("Invalid initialVersion '${settings.initialVersion}' — must be MAJOR.MINOR.PATCH") + InferredVersion( + version = composeVersion(seed.toString(), repo, isRelease = false), + isRelease = false, + source = VersionSource.Initial, + ) + } + + is NearestTag.Found -> { + val parsed = SemVer.parseOrNull(nearest.tag.versionString) + ?: error( + "Tag '${nearest.tag.name}' couldn't be parsed as SemVer after stripping " + + "prefix '${settings.tagPrefix}${settings.versionSeparator}'", + ) + val onTag = nearest.onHead && !forceSnapshot + val effective = if (onTag) parsed else parsed.incrementPatch() + InferredVersion( + version = composeVersion(effective.toString(), repo, isRelease = onTag), + isRelease = onTag, + source = VersionSource.Tag, + ) + } + } + } + + /** + * The next release version that [createRelease][io.sdkman.kotlintoolchain.plugins.release.tasks.createRelease] + * would tag at HEAD: bare SemVer, no decoration, no `-SNAPSHOT`. + * + * Returns `null` when HEAD is already exactly on a release tag — in that + * case there's nothing to release. + */ + fun nextReleaseVersion(repo: GitRepo): SemVer? { + val forceVersion = env["RELEASE_FORCE_VERSION"]?.takeIf { it.isNotBlank() } + if (forceVersion != null) { + return SemVer.parseOrNull(forceVersion) + ?: error("RELEASE_FORCE_VERSION='$forceVersion' is not a valid MAJOR.MINOR.PATCH") + } + return when (val nearest = repo.nearestTagFromHead(settings.tagPrefix, settings.versionSeparator)) { + NearestTag.None -> SemVer.parseOrNull(settings.initialVersion) + ?: error("Invalid initialVersion '${settings.initialVersion}' — must be MAJOR.MINOR.PATCH") + + is NearestTag.Found -> { + if (nearest.onHead) null + else (SemVer.parseOrNull(nearest.tag.versionString) + ?: error("Tag '${nearest.tag.name}' isn't a valid SemVer")).incrementPatch() + } + } + } + + /** Compose the full Git tag name for [version], honoring prefix and separator. */ + fun tagNameFor(version: SemVer): String = + settings.tagPrefix + settings.versionSeparator + version.toString() + + /** + * Branch name used for decoration and the release-branch gate. + * + * `RELEASE_OVERRIDDEN_BRANCH_NAME` always wins. Otherwise a detached HEAD — + * the default checkout state on CI (`actions/checkout`) — must not + * contribute a branch name: JGit reports the abbreviated commit SHA there, + * which would leak into the decorated version (e.g. `1.2.4-`) and + * spuriously trip the release-branch gate. Report it as "no branch" so + * decoration is skipped and the gate isn't matched against a SHA. Set the + * override to opt back into branch-aware behavior on a detached checkout. + */ + fun effectiveBranchName(repo: GitRepo): String { + env["RELEASE_OVERRIDDEN_BRANCH_NAME"]?.takeIf { it.isNotBlank() }?.let { return it } + if (repo.isDetached()) return "" + return repo.branchName() + } + + /** + * Compose the final version string from [base], in axion's phase order: + * decorate, then append `-SNAPSHOT` for non-release builds, then sanitize. + * + * [decorate] runs for releases too — it is what keeps them bare. axion's + * `versionWithBranch` skips `main`/`master` and detached HEAD, and the + * release workflow resolves the version while HEAD is detached, so the + * released version (and what is stamped into `release.properties` / + * `version.txt`) stays bare (e.g. `1.2.4`); a dev build on a named branch + * becomes `1.2.4--SNAPSHOT`. + */ + private fun composeVersion(base: String, repo: GitRepo, isRelease: Boolean): String { + val decorated = decorate(base, repo) + return sanitize(if (isRelease) decorated else appendSnapshot(decorated)) + } + + private fun decorate(version: String, repo: GitRepo): String { + val branch = effectiveBranchName(repo) + if (branch.isBlank()) return version + // axion's "versionWithBranch": main/master are excluded; everything else + // gets the sanitized branch suffix. + if (branch == "main" || branch == "master") return version + return "$version-${sanitize(branch)}" + } + + private fun appendSnapshot(s: String): String = "$s-SNAPSHOT" + + private fun sanitize(s: String): String = s.replace(SANITIZE_PATTERN, "-") + + private companion object { + val SANITIZE_PATTERN = Regex("[^A-Za-z0-9._-]") + } +} + +private fun String?.asBoolean(): Boolean = + this != null && this.equals("true", ignoreCase = true) diff --git a/plugins/release/src/version/SemVer.kt b/plugins/release/src/version/SemVer.kt new file mode 100644 index 0000000..7403551 --- /dev/null +++ b/plugins/release/src/version/SemVer.kt @@ -0,0 +1,30 @@ +package io.sdkman.kotlintoolchain.plugins.release.version + +/** + * Minimal `MAJOR.MINOR.PATCH` Semantic Versioning value class. + * + * Pre-release identifiers and build metadata aren't supported in MVP — the + * release plugin only ever produces decorated versions like `1.2.3-foo` via + * the [VersionPipeline], not raw SemVer pre-releases on tags themselves. + */ +data class SemVer(val major: Int, val minor: Int, val patch: Int) : Comparable { + override fun toString(): String = "$major.$minor.$patch" + + fun incrementPatch(): SemVer = copy(patch = patch + 1) + + override fun compareTo(other: SemVer): Int = + compareValuesBy(this, other, SemVer::major, SemVer::minor, SemVer::patch) + + companion object { + private val PATTERN = Regex("""^(\d+)\.(\d+)\.(\d+)$""") + + fun parseOrNull(s: String): SemVer? { + val m = PATTERN.matchEntire(s.trim()) ?: return null + val (maj, min, pat) = m.destructured + return SemVer(maj.toInt(), min.toInt(), pat.toInt()) + } + + fun parse(s: String): SemVer = + parseOrNull(s) ?: error("'$s' is not a valid MAJOR.MINOR.PATCH version") + } +} diff --git a/plugins/release/test/TestGitRepo.kt b/plugins/release/test/TestGitRepo.kt new file mode 100644 index 0000000..e0d6612 --- /dev/null +++ b/plugins/release/test/TestGitRepo.kt @@ -0,0 +1,60 @@ +package io.sdkman.kotlintoolchain.plugins.release + +import io.sdkman.kotlintoolchain.plugins.release.git.GitRepo +import org.eclipse.jgit.api.Git +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.div +import kotlin.io.path.writeText + +/** [Settings] with all default values; only [Settings.checks] needs a concrete value. */ +fun testSettings(): Settings = object : Settings { + override val checks: ChecksSettings = object : ChecksSettings {} +} + +/** + * A throwaway on-disk Git repository for exercising [GitRepo]-backed logic in tests. + * + * Each [commit] writes a file so the commit is non-empty and distinct. Commits and tags + * use a fixed identity and are unsigned, so tests don't depend on the developer's global + * git config. + */ +class TestGitRepo : AutoCloseable { + val dir: Path = Files.createTempDirectory("release-plugin-test") + private val git: Git = Git.init().setDirectory(dir.toFile()).setInitialBranch("main").call() + + fun commit(message: String): TestGitRepo { + (dir / "f.txt").writeText(message) + git.add().addFilepattern(".").call() + git.commit() + .setMessage(message) + .setAuthor("test", "test@example.com") + .setCommitter("test", "test@example.com") + .setSign(false) + .call() + return this + } + + fun tag(name: String): TestGitRepo { + git.tag().setName(name).setAnnotated(true).setMessage(name).setSigned(false).call() + return this + } + + fun branch(name: String): TestGitRepo { + git.checkout().setCreateBranch(true).setName(name).call() + return this + } + + fun detach(): TestGitRepo { + val head = git.repository.resolve("HEAD") ?: error("repository has no HEAD") + git.checkout().setName(head.name).call() + return this + } + + fun repo(): GitRepo = GitRepo.open(dir, "") + + override fun close() { + git.close() + dir.toFile().deleteRecursively() + } +} diff --git a/plugins/release/test/tasks/PushReleaseTest.kt b/plugins/release/test/tasks/PushReleaseTest.kt new file mode 100644 index 0000000..3348be1 --- /dev/null +++ b/plugins/release/test/tasks/PushReleaseTest.kt @@ -0,0 +1,47 @@ +package io.sdkman.kotlintoolchain.plugins.release.tasks + +import io.sdkman.kotlintoolchain.plugins.release.TestGitRepo +import io.sdkman.kotlintoolchain.plugins.release.testSettings +import io.sdkman.kotlintoolchain.plugins.release.version.VersionPipeline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PushReleaseTest { + private fun pipeline(env: Map = emptyMap()) = VersionPipeline(testSettings(), env) + + @Test + fun returnsTheSingleTagAtHead() { + TestGitRepo().use { r -> + r.commit("init").tag("v1.0.0") + assertEquals("v1.0.0", pickTagToPush(r.repo(), testSettings(), pipeline(), emptyMap())) + } + } + + @Test + fun picksHighestSemVerWhenMultipleTagsAtHead() { + TestGitRepo().use { r -> + r.commit("init").tag("v1.0.0").tag("v1.2.0").tag("v1.1.0") + assertEquals("v1.2.0", pickTagToPush(r.repo(), testSettings(), pipeline(), emptyMap())) + } + } + + @Test + fun failsWhenNoTagAtHead() { + TestGitRepo().use { r -> + r.commit("init") + assertFailsWith { + pickTagToPush(r.repo(), testSettings(), pipeline(), emptyMap()) + } + } + } + + @Test + fun forceVersionEnvSelectsThatTagEvenWhenHeadIsElsewhere() { + TestGitRepo().use { r -> + r.commit("init").tag("v3.0.0").commit("work") + val env = mapOf("RELEASE_FORCE_VERSION" to "3.0.0") + assertEquals("v3.0.0", pickTagToPush(r.repo(), testSettings(), pipeline(env), env)) + } + } +} diff --git a/plugins/release/test/version/SemVerTest.kt b/plugins/release/test/version/SemVerTest.kt new file mode 100644 index 0000000..f776520 --- /dev/null +++ b/plugins/release/test/version/SemVerTest.kt @@ -0,0 +1,30 @@ +package io.sdkman.kotlintoolchain.plugins.release.version + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SemVerTest { + @Test + fun parsesValidVersion() { + assertEquals(SemVer(1, 2, 3), SemVer.parseOrNull("1.2.3")) + } + + @Test + fun trimsSurroundingWhitespace() { + assertEquals(SemVer(0, 1, 67), SemVer.parseOrNull(" 0.1.67 ")) + } + + @Test + fun rejectsNonSemVerStrings() { + assertNull(SemVer.parseOrNull("1.2")) + assertNull(SemVer.parseOrNull("v1.2.3")) + assertNull(SemVer.parseOrNull("1.2.3-rc1")) + assertNull(SemVer.parseOrNull("")) + } + + @Test + fun incrementsPatchOnly() { + assertEquals(SemVer(1, 2, 4), SemVer(1, 2, 3).incrementPatch()) + } +} diff --git a/plugins/release/test/version/VersionPipelineTest.kt b/plugins/release/test/version/VersionPipelineTest.kt new file mode 100644 index 0000000..c652994 --- /dev/null +++ b/plugins/release/test/version/VersionPipelineTest.kt @@ -0,0 +1,84 @@ +package io.sdkman.kotlintoolchain.plugins.release.version + +import io.sdkman.kotlintoolchain.plugins.release.TestGitRepo +import io.sdkman.kotlintoolchain.plugins.release.testSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VersionPipelineTest { + private fun pipeline(env: Map = emptyMap()) = VersionPipeline(testSettings(), env) + + @Test + fun headOnTagIsABareRelease() { + TestGitRepo().use { r -> + r.commit("init").tag("v1.2.3") + val inferred = pipeline().infer(r.repo()) + assertEquals("1.2.3", inferred.version) + assertTrue(inferred.isRelease) + } + } + + @Test + fun detachedHeadOnTagStaysBare() { + // The release workflow resolves the version on a detached HEAD; it must not + // decorate the released version with the commit SHA. + TestGitRepo().use { r -> + r.commit("init").tag("v1.2.3").detach() + val inferred = pipeline().infer(r.repo()) + assertEquals("1.2.3", inferred.version) + assertTrue(inferred.isRelease) + } + } + + @Test + fun featureBranchSnapshotIsDecoratedAndSanitized() { + TestGitRepo().use { r -> + r.commit("init").tag("v1.2.3").branch("feature/x").commit("work") + val inferred = pipeline().infer(r.repo()) + assertEquals("1.2.4-feature-x-SNAPSHOT", inferred.version) + assertFalse(inferred.isRelease) + } + } + + @Test + fun mainSnapshotIsBareSnapshot() { + TestGitRepo().use { r -> + r.commit("init").tag("v1.2.3").commit("work") + val inferred = pipeline().infer(r.repo()) + assertEquals("1.2.4-SNAPSHOT", inferred.version) + assertFalse(inferred.isRelease) + } + } + + @Test + fun noTagsUsesInitialVersionAsSnapshot() { + TestGitRepo().use { r -> + r.commit("init") + val inferred = pipeline().infer(r.repo()) + assertEquals("0.1.0-SNAPSHOT", inferred.version) + assertFalse(inferred.isRelease) + } + } + + @Test + fun forceVersionShortCircuitsToBareRelease() { + TestGitRepo().use { r -> + r.commit("init").tag("v1.2.3").commit("work") + val inferred = pipeline(mapOf("RELEASE_FORCE_VERSION" to "9.9.9")).infer(r.repo()) + assertEquals("9.9.9", inferred.version) + assertTrue(inferred.isRelease) + } + } + + @Test + fun overriddenBranchNameDecoratesEvenOnDetachedHead() { + TestGitRepo().use { r -> + r.commit("init").tag("v1.2.3").commit("work").detach() + val inferred = pipeline(mapOf("RELEASE_OVERRIDDEN_BRANCH_NAME" to "release/9")).infer(r.repo()) + assertEquals("1.2.4-release-9-SNAPSHOT", inferred.version) + assertFalse(inferred.isRelease) + } + } +} diff --git a/project.yaml b/project.yaml new file mode 100644 index 0000000..45d386d --- /dev/null +++ b/project.yaml @@ -0,0 +1,13 @@ +modules: + - plugins/detekt + - plugins/jib + - plugins/ktlint + - plugins/package + - plugins/release + +plugins: + - ./plugins/detekt + - ./plugins/jib + - ./plugins/ktlint + - ./plugins/package + - ./plugins/release