diff options
Diffstat (limited to 'dokka-runners/dokkatoo/devOps/release.main.kts')
-rw-r--r-- | dokka-runners/dokkatoo/devOps/release.main.kts | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/dokka-runners/dokkatoo/devOps/release.main.kts b/dokka-runners/dokkatoo/devOps/release.main.kts new file mode 100644 index 00000000..a7555719 --- /dev/null +++ b/dokka-runners/dokkatoo/devOps/release.main.kts @@ -0,0 +1,415 @@ +#!/usr/bin/env kotlin +@file:DependsOn("com.github.ajalt.clikt:clikt-jvm:3.5.2") +@file:DependsOn("me.alllex.parsus:parsus-jvm:0.4.0") +@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3") + +import Release_main.SemVer.Companion.SemVer +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import java.io.File +import java.util.concurrent.TimeUnit.MINUTES +import kotlin.system.exitProcess +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import me.alllex.parsus.parser.* +import me.alllex.parsus.token.literalToken +import me.alllex.parsus.token.regexToken + +try { + Release.main(args) + exitProcess(0) +} catch (ex: Exception) { + println("${ex::class.simpleName}: ${ex.message}") + exitProcess(1) +} + +/** + * Release a new version. + * + * Requires: + * * [gh cli](https://cli.github.com/manual/gh) + * * [kotlin](https://kotlinlang.org/docs/command-line.html) + * * [git](https://git-scm.com/) + */ +// based on https://github.com/apollographql/apollo-kotlin/blob/v4.0.0-dev.2/scripts/release.main.kts +object Release : CliktCommand() { + private val skipGitValidation by option( + "--skip-git-validation", + help = "skips git status validation" + ).flag(default = false) + + override fun run() { + echo("Current Dokkatoo version is $dokkatooVersion") + echo("git dir is ${Git.rootDir}") + + val startBranch = Git.currentBranch() + + validateGitStatus(startBranch) + + val releaseVersion = semverPrompt( + text = "version to release?", + default = dokkatooVersion.copy(snapshot = false), + ) { + if (it.snapshot) { + echo("versionToRelease must not be a snapshot version, but was $it") + } + !it.snapshot + } + val nextVersion = semverPrompt( + text = "post-release version?", + default = releaseVersion.incrementMinor(snapshot = true), + ) + updateVersionCreatePR(releaseVersion) + + // switch back to the main branch + Git.switch(startBranch) + Git.pull(startBranch) + + // Tag the release + createAndPushTag(releaseVersion) + + confirm("Publish plugins to Gradle Plugin Portal?", abort = true) + Gradle.publishPlugins() + + // Bump the version to the next snapshot + updateVersionCreatePR(nextVersion) + + // Go back and pull the changes + Git.switch(startBranch) + Git.pull(startBranch) + + echo("Released version $releaseVersion") + } + + private fun validateGitStatus(startBranch: String) { + if (skipGitValidation) { + echo("skipping git status validation") + return + } + check(Git.status().isEmpty()) { + "git repo is not clean. Stash or commit changes before making a release." + } + check(dokkatooVersion.snapshot) { + "Current version must be a SNAPSHOT, but was $dokkatooVersion" + } + check(startBranch == "main") { + "Must be on the main branch to make a release, but current branch is $startBranch" + } + } + + /** + * @param[validate] returns `null` if the provided SemVer is valid, or else an error message + * explaining why it is invalid. + */ + private tailrec fun semverPrompt( + text: String, + default: SemVer, + validate: (candidate: SemVer) -> Boolean = { true }, + ): SemVer { + val response = prompt( + text = text, + default = default.toString(), + requireConfirmation = true, + ) { + SemVer.of(it) + } + + return if (response == null || !validate(response)) { + if (response == null) echo("invalid SemVer") + semverPrompt(text, default, validate) + } else { + response + } + } + + private fun updateVersionCreatePR(version: SemVer) { + // checkout a release branch + val releaseBranch = "release/v$version" + echo("checkout out new branch...") + Git.switch(releaseBranch, create = true) + + // update the version & run tests + dokkatooVersion = version + echo("running Gradle check...") + Gradle.check() + + // commit and push + echo("committing...") + Git.commit("release $version") + echo("pushing...") + Git.push(releaseBranch) + + // create a new PR + echo("creating PR...") + GitHub.createPr(releaseBranch) + + confirm("Merge the PR for branch $releaseBranch?", abort = true) + mergeAndWait(releaseBranch) + echo("$releaseBranch PR merged") + } + + private fun createAndPushTag(version: SemVer) { + // Tag the release + require(dokkatooVersion == version) { + "tried to create a tag, but project version does not match provided version. Expected $version but got $dokkatooVersion" + } + val tagName = "v$version" + Git.tag(tagName) + confirm("Push tag $tagName?", abort = true) + Git.push(tagName) + echo("Tag pushed") + + confirm("Publish plugins to Gradle Plugin Portal?", abort = true) + Gradle.publishPlugins() + } + + private val buildGradleKts: File by lazy { + val rootDir = Git.rootDir + File("$rootDir/build.gradle.kts").apply { + require(exists()) { "could not find build.gradle.kts in $rootDir" } + } + } + + /** Read/write the version set in the root `build.gradle.kts` file */ + private var dokkatooVersion: SemVer + get() { + val rawVersion = Gradle.dokkatooVersion() + return SemVer(rawVersion) + } + set(value) { + val updatedFile = buildGradleKts.useLines { lines -> + lines.joinToString(separator = "\n", postfix = "\n") { line -> + if (line.startsWith("version = ")) { + "version = \"${value}\"" + } else { + line + } + } + } + buildGradleKts.writeText(updatedFile) + } + + private fun mergeAndWait(branchName: String): Unit = runBlocking { + GitHub.autoMergePr(branchName) + echo("Waiting for the PR to be merged...") + while (GitHub.prState(branchName) != "MERGED") { + delay(1.seconds) + echo(".", trailingNewline = false) + } + } +} + +private abstract class CliTool { + + protected fun runCommand( + cmd: String, + dir: File? = Git.rootDir, + logOutput: Boolean = true, + ): String { + val args = parseSpaceSeparatedArgs(cmd) + + val process = ProcessBuilder(args).apply { + redirectOutput(ProcessBuilder.Redirect.PIPE) + redirectInput(ProcessBuilder.Redirect.PIPE) + redirectErrorStream(true) + if (dir != null) directory(dir) + }.start() + + val processOutput = process.inputStream + .bufferedReader() + .lineSequence() + .onEach { if (logOutput) println("\t$it") } + .joinToString("\n") + .trim() + + process.waitFor(10, MINUTES) + + val exitCode = process.exitValue() + + if (exitCode != 0) { + error("command '$cmd' failed:\n${processOutput}") + } + + return processOutput + } + + private data class ProcessResult( + val exitCode: Int, + val output: String, + ) + + companion object { + private fun parseSpaceSeparatedArgs(argsString: String): List<String> { + val parsedArgs = mutableListOf<String>() + var inQuotes = false + var currentCharSequence = StringBuilder() + fun saveArg(wasInQuotes: Boolean) { + if (wasInQuotes || currentCharSequence.isNotBlank()) { + parsedArgs.add(currentCharSequence.toString()) + currentCharSequence = StringBuilder() + } + } + argsString.forEach { char -> + if (char == '"') { + inQuotes = !inQuotes + // Save value which was in quotes. + if (!inQuotes) { + saveArg(true) + } + } else if (char.isWhitespace() && !inQuotes) { + // Space is separator + saveArg(false) + } else { + currentCharSequence.append(char) + } + } + if (inQuotes) { + error("No close-quote was found in $currentCharSequence.") + } + saveArg(false) + return parsedArgs + } + } +} + +/** git commands */ +private object Git : CliTool() { + val rootDir = File(runCommand("git rev-parse --show-toplevel", dir = null)) + + init { + require(rootDir.exists()) { "could not determine root git directory" } + } + + fun switch(branch: String, create: Boolean = false): String { + return runCommand( + buildString { + append("git switch ") + if (create) append("--create ") + append(branch) + } + ) + } + + fun commit(message: String): String = runCommand("git commit -a -m \"$message\"") + fun currentBranch(): String = runCommand("git symbolic-ref --short HEAD") + fun pull(ref: String): String = runCommand("git pull origin $ref") + fun push(ref: String): String = runCommand("git push origin $ref") + fun status(): String { + runCommand("git fetch --all") + return runCommand("git status --porcelain=v2") + } + + fun tag(tag: String): String { + return runCommand("git tag $tag") + } +} + +/** GitHub commands */ +private object GitHub : CliTool() { + + init { + setRepo("adamko-dev/dokkatoo") + } + + fun setRepo(repo: String): String = + runCommand("gh repo set-default $repo") + + fun prState(branchName: String): String = + runCommand("gh pr view $branchName --json state --jq .state", logOutput = false) + + fun createPr(branch: String): String = + runCommand("gh pr create --head $branch --fill") + + fun autoMergePr(branch: String): String = + runCommand("gh pr merge $branch --squash --auto --delete-branch") + + fun waitForPrChecks(branch: String): String = + runCommand("gh pr checks $branch --watch --interval 30") +} + +/** GitHub commands */ +private object Gradle : CliTool() { + + val gradlew: String + + init { + val osName = System.getProperty("os.name").lowercase() + gradlew = if ("win" in osName) "./gradlew.bat" else "./gradlew" + } + + fun stopDaemons(): String = runCommand("$gradlew --stop") + + fun dokkatooVersion(): String { + stopDaemons() + return runCommand("$gradlew :dokkatooVersion --quiet --no-daemon") + } + + fun check(): String { + stopDaemons() + return runCommand("$gradlew check --no-daemon") + } + + fun publishPlugins(): String { + stopDaemons() + return runCommand("$gradlew publishPlugins --no-daemon --no-configuration-cache") + } +} + +private data class SemVer( + val major: Int, + val minor: Int, + val patch: Int, + val snapshot: Boolean, +) { + + fun incrementMinor(snapshot: Boolean): SemVer = + copy(minor = minor + 1, snapshot = snapshot) + + override fun toString(): String = + "$major.$minor.$patch" + if (snapshot) "-SNAPSHOT" else "" + + companion object { + fun SemVer(input: String): SemVer = + SemVerParser.parseEntire(input).getOrElse { error -> + error("provided version to release must be SemVer X.Y.Z, but got error while parsing: $error") + } + + fun of(input: String): SemVer? = + SemVerParser.parseEntire(input).getOrElse { return null } + + fun isValid(input: String): Boolean = + try { + SemVerParser.parseEntireOrThrow(input) + true + } catch (ex: ParseException) { + false + } + } + + private object SemVerParser : Grammar<SemVer>() { + private val dotSeparator by literalToken(".") + private val dashSeparator by literalToken("-") + + /** Non-negative number that is either 0, or does not start with 0 */ + private val number: Parser<Int> by regexToken("""0|[1-9]\d*""").map { it.text.toInt() } + + private val snapshot by -dashSeparator * literalToken("SNAPSHOT") + + override val root: Parser<SemVer> by parser { + val major = number() + dotSeparator() + val minor = number() + dotSeparator() + val patch = number() + val snapshot = checkPresent(snapshot) + SemVer( + major = major, + minor = minor, + patch = patch, + snapshot = snapshot, + ) + } + } +} |