From 0ffc4802a886931a53d977b35b3d803f6da91a20 Mon Sep 17 00:00:00 2001 From: Adam <897017+aSemy@users.noreply.github.com> Date: Wed, 14 Jun 2023 14:32:46 +0200 Subject: [PATCH 1/4] create a `.kts` script for releasing a new version --- buildSrc/build.gradle.kts | 1 - devOps/release.main.kts | 282 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 devOps/release.main.kts diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 32af8984..832a98b9 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,7 +10,6 @@ dependencies { implementation(libs.gradlePlugin.dokkatoo) implementation(libs.gradlePlugin.gradlePublishPlugin) implementation("org.jetbrains.kotlin:kotlin-serialization:$embeddedKotlinVersion") - } java { diff --git a/devOps/release.main.kts b/devOps/release.main.kts new file mode 100644 index 00000000..fbc4d6e7 --- /dev/null +++ b/devOps/release.main.kts @@ -0,0 +1,282 @@ +#!/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") + +import Release_main.SemVer.Companion.SemVer +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.prompt +import java.io.File +import java.lang.ProcessBuilder.Redirect.INHERIT +import me.alllex.parsus.parser.* +import me.alllex.parsus.token.literalToken +import me.alllex.parsus.token.regexToken + +/** + * 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 versionToRelease by option(help = "version to release") + .prompt("versionToRelease") + + override fun run() { + val versionToRelease = SemVer(versionToRelease) + val nextVersion = versionToRelease.copy( + minor = versionToRelease.minor + 1, + snapshot = true + ) + + //region Validation +// check(currentDir() == git.rootDir()) { +// "must run release.main.kts in the root " +// } + check(!versionToRelease.snapshot) { + "versionToRelease must not be a snapshot version, but was $versionToRelease" + } + + check(git.status().isEmpty()) { + "git repo is not clean. Stash or commit changes before making a release." + } + check(getCurrentVersion().snapshot) { + "Current version must be a SNAPSHOT, but was ${getCurrentVersion()}" + } + + val startBranch = git.currentBranch() + check(startBranch == "main") { + "Must be on the main branch to make a release, but current branch is $startBranch" + } + //endregion + + echo("Current version is ${getCurrentVersion()}") + confirm("Release $versionToRelease and bump to $nextVersion?", abort = true) + + updateAndRelease(versionToRelease) + + // Tag the release + git.checkout(startBranch) + git.pull(startBranch) + require(getCurrentVersion() == versionToRelease) { + "incorrect version after update. Expected $versionToRelease but got ${getCurrentVersion()}" + } + val tagName = "v$versionToRelease" + git.tag(tagName) + confirm("Push tag $tagName?", abort = true) + git.push(tagName) + echo("Tag pushed") + + // Bump the version to the next snapshot + updateAndRelease(nextVersion) + + // Go back and pull the changes + git.checkout(startBranch) + git.pull(startBranch) + + echo("Released version $versionToRelease") + } + + private fun updateAndRelease(version: SemVer) { + // checkout a release branch + val releaseBranch = "release-$version" + git.checkout(releaseBranch) + + // update the version & run tests + setCurrentVersion(version) + gradle.check() + + // commit and push + git.commit("release $version") + git.push(releaseBranch) + + // create a new PR + gh.createPr(releaseBranch) + + confirm("Merge the PR for branch $releaseBranch?", abort = true) + mergeAndWait(releaseBranch) + echo("$releaseBranch PR merged") + } + + /** git commands */ + private val git = object { + fun checkout(branch: String): String = runCommand("git checkout -b $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 rootDir(): String = runCommand("git rev-parse --show-toplevel") + fun status(): String = runCommand("git status --porcelain=v2") + fun tag(tag: String): String = runCommand("git tag $tag") + } + + /** GitHub commands */ + private val gh = object { + fun prState(branchName: String): String = + runCommand("gh pr view $branchName --json state --jq .state") + + fun createPr(branch: String): String = + runCommand("gh pr create --base $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 val gradle = object { + fun check(): String = runCommand("gradlew check") + } + + // private val currentDir: String get() = System.getProperty("user.dir") + + private val buildGradleKts: File by lazy { + File(git.rootDir()).resolve("build.gradle.kts").also { + require(it.exists()) { "could not find build.gradle.kts in ${git.rootDir()}" } + } + } + + private fun runCommand(cmd: String): String { + val args = parseSpaceSeparatedArgs(cmd) + + val process = ProcessBuilder(*args.toTypedArray()) + .redirectError(INHERIT) + .start() + + val ret = process.waitFor() + + val output = process.inputStream.bufferedReader().use { it.readText() } + if (ret != 0) { + error("command '$cmd' failed:\n$output") + } + + return output.trim() + } + + private fun getCurrentVersion(): SemVer { + val versionLine = buildGradleKts.useLines { lines -> + lines.firstOrNull { it.startsWith("version = ") } + } + + requireNotNull(versionLine) { "cannot find version in $buildGradleKts" } + + val rawVersion = versionLine.substringAfter("\"").substringBefore("\"") + + return SemVer(rawVersion) + } + + private fun setCurrentVersion(newVersion: SemVer) { + val updatedFile = buildGradleKts.useLines { lines -> + lines.joinToString("\n") { line -> + if (line.startsWith("version = ")) { + "version = \"${newVersion}\"" + } else { + line + } + } + } + buildGradleKts.writeText(updatedFile) + } + + private fun mergeAndWait(branchName: String) { + gh.autoMergePr(branchName) + echo("Waiting for the PR to be merged...") + while (gh.prState(branchName) != "MERGED") { + Thread.sleep(1_000) + echo(".", trailingNewline = false) + } + } + + private fun parseSpaceSeparatedArgs(argsString: String): List { + val parsedArgs = mutableListOf() + 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 + } + +} + + +Release.main(args) + + +private data class SemVer( + val major: Int, + val minor: Int, + val patch: Int, + val snapshot: Boolean, +) { + + 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 isValid(input: String): Boolean = + try { + SemVerParser.parseEntireOrThrow(input) + true + } catch (ex: ParseException) { + false + } + } + + private object SemVerParser : Grammar() { + 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 by regexToken("""0|[1-9]\d*""").map { it.text.toInt() } + + private val metadata by optional(-dashSeparator * regexToken(""".+""")) + .map { it?.text ?: "" } + + override val root: Parser by parser { + val major = number() + dotSeparator() + val minor = number() + dotSeparator() + val patch = number() + val metadata = metadata() + SemVer( + major = major, + minor = minor, + patch = patch, + snapshot = metadata == "SNAPSHOT", + ) + } + } +} From 4323c2fb33b26b1cac7c13d235894859b867edc1 Mon Sep 17 00:00:00 2001 From: Adam <897017+aSemy@users.noreply.github.com> Date: Wed, 14 Jun 2023 14:50:53 +0200 Subject: [PATCH 2/4] +x --- devOps/release.main.kts | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 devOps/release.main.kts diff --git a/devOps/release.main.kts b/devOps/release.main.kts old mode 100644 new mode 100755 From e0d0b7c149d82bcd713a54d608c3d8af81b3637e Mon Sep 17 00:00:00 2001 From: Adam <897017+aSemy@users.noreply.github.com> Date: Wed, 14 Jun 2023 15:22:12 +0200 Subject: [PATCH 3/4] tweaking release script --- devOps/release.main.kts | 124 +++++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 53 deletions(-) diff --git a/devOps/release.main.kts b/devOps/release.main.kts index fbc4d6e7..da0c5d3b 100755 --- a/devOps/release.main.kts +++ b/devOps/release.main.kts @@ -4,8 +4,6 @@ import Release_main.SemVer.Companion.SemVer import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.prompt import java.io.File import java.lang.ProcessBuilder.Redirect.INHERIT import me.alllex.parsus.parser.* @@ -22,38 +20,39 @@ import me.alllex.parsus.token.regexToken */ // based on https://github.com/apollographql/apollo-kotlin/blob/v4.0.0-dev.2/scripts/release.main.kts object Release : CliktCommand() { - private val versionToRelease by option(help = "version to release") - .prompt("versionToRelease") override fun run() { - val versionToRelease = SemVer(versionToRelease) - val nextVersion = versionToRelease.copy( - minor = versionToRelease.minor + 1, - snapshot = true - ) //region Validation -// check(currentDir() == git.rootDir()) { -// "must run release.main.kts in the root " -// } - check(!versionToRelease.snapshot) { - "versionToRelease must not be a snapshot version, but was $versionToRelease" - } - check(git.status().isEmpty()) { "git repo is not clean. Stash or commit changes before making a release." } - check(getCurrentVersion().snapshot) { - "Current version must be a SNAPSHOT, but was ${getCurrentVersion()}" + check(currentVersion.snapshot) { + "Current version must be a SNAPSHOT, but was $currentVersion" } - val startBranch = git.currentBranch() check(startBranch == "main") { "Must be on the main branch to make a release, but current branch is $startBranch" } //endregion - echo("Current version is ${getCurrentVersion()}") + echo("Current version is $currentVersion") + + val versionToRelease = prompt( + text = "version to release?", + default = currentVersion.incrementMinor(snapshot = false).toString(), + requireConfirmation = true, + ) { + SemVer.of(it) + } ?: error("invalid SemVer") + + check(!versionToRelease.snapshot) { + "versionToRelease must not be a snapshot version, but was $versionToRelease" + } + + val nextVersion = versionToRelease.incrementMinor(snapshot = true) + + echo("Current version is $currentVersion") confirm("Release $versionToRelease and bump to $nextVersion?", abort = true) updateAndRelease(versionToRelease) @@ -61,8 +60,8 @@ object Release : CliktCommand() { // Tag the release git.checkout(startBranch) git.pull(startBranch) - require(getCurrentVersion() == versionToRelease) { - "incorrect version after update. Expected $versionToRelease but got ${getCurrentVersion()}" + require(currentVersion == versionToRelease) { + "incorrect version after update. Expected $versionToRelease but got $currentVersion" } val tagName = "v$versionToRelease" git.tag(tagName) @@ -86,7 +85,7 @@ object Release : CliktCommand() { git.checkout(releaseBranch) // update the version & run tests - setCurrentVersion(version) + currentVersion = version gradle.check() // commit and push @@ -103,14 +102,20 @@ object Release : CliktCommand() { /** git commands */ private val git = object { + val rootDir = File(runCommand("git rev-parse --show-toplevel", dir = null)) fun checkout(branch: String): String = runCommand("git checkout -b $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 rootDir(): String = runCommand("git rev-parse --show-toplevel") - fun status(): String = runCommand("git status --porcelain=v2") - fun tag(tag: String): String = runCommand("git tag $tag") + 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 */ @@ -135,18 +140,25 @@ object Release : CliktCommand() { // private val currentDir: String get() = System.getProperty("user.dir") - private val buildGradleKts: File by lazy { - File(git.rootDir()).resolve("build.gradle.kts").also { - require(it.exists()) { "could not find build.gradle.kts in ${git.rootDir()}" } + private val buildGradleKts: File + get() { + val rootDir = git.rootDir + echo("rootDir: $rootDir") + return File("$rootDir/build.gradle.kts").apply { + require(exists()) { "could not find build.gradle.kts in ${git.rootDir}" } + } } - } - private fun runCommand(cmd: String): String { + private fun runCommand( + cmd: String, + dir: File? = git.rootDir, + ): String { val args = parseSpaceSeparatedArgs(cmd) - val process = ProcessBuilder(*args.toTypedArray()) - .redirectError(INHERIT) - .start() + val process = ProcessBuilder(*args.toTypedArray()).apply { + redirectError(INHERIT) + if (dir != null) directory(dir) + }.start() val ret = process.waitFor() @@ -158,30 +170,31 @@ object Release : CliktCommand() { return output.trim() } - private fun getCurrentVersion(): SemVer { - val versionLine = buildGradleKts.useLines { lines -> - lines.firstOrNull { it.startsWith("version = ") } - } + /** Read/write the version set in the root `build.gradle.kts` file */ + private var currentVersion: SemVer + get() { + val versionLine = buildGradleKts.useLines { lines -> + lines.firstOrNull { it.startsWith("version = ") } + } - requireNotNull(versionLine) { "cannot find version in $buildGradleKts" } + requireNotNull(versionLine) { "cannot find version in $buildGradleKts" } - val rawVersion = versionLine.substringAfter("\"").substringBefore("\"") + val rawVersion = versionLine.substringAfter("\"").substringBefore("\"") - return SemVer(rawVersion) - } - - private fun setCurrentVersion(newVersion: SemVer) { - val updatedFile = buildGradleKts.useLines { lines -> - lines.joinToString("\n") { line -> - if (line.startsWith("version = ")) { - "version = \"${newVersion}\"" - } else { - line + return SemVer(rawVersion) + } + set(value) { + val updatedFile = buildGradleKts.useLines { lines -> + lines.joinToString("\n") { line -> + if (line.startsWith("version = ")) { + "version = \"${value}\"" + } else { + line + } } } + buildGradleKts.writeText(updatedFile) } - buildGradleKts.writeText(updatedFile) - } private fun mergeAndWait(branchName: String) { gh.autoMergePr(branchName) @@ -222,7 +235,6 @@ object Release : CliktCommand() { saveArg(false) return parsedArgs } - } @@ -236,6 +248,9 @@ private data class SemVer( 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 "" @@ -245,6 +260,9 @@ private data class SemVer( 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) From 1a573887e951d8d28b4c0530ab09183bc3eaa25d Mon Sep 17 00:00:00 2001 From: Adam <897017+aSemy@users.noreply.github.com> Date: Wed, 14 Jun 2023 15:35:59 +0200 Subject: [PATCH 4/4] adjust SemVer parser --- devOps/release.main.kts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/devOps/release.main.kts b/devOps/release.main.kts index da0c5d3b..f61934b0 100755 --- a/devOps/release.main.kts +++ b/devOps/release.main.kts @@ -279,8 +279,7 @@ private data class SemVer( /** Non-negative number that is either 0, or does not start with 0 */ private val number: Parser by regexToken("""0|[1-9]\d*""").map { it.text.toInt() } - private val metadata by optional(-dashSeparator * regexToken(""".+""")) - .map { it?.text ?: "" } + private val snapshot by -dashSeparator * literalToken("SNAPSHOT") override val root: Parser by parser { val major = number() @@ -288,12 +287,12 @@ private data class SemVer( val minor = number() dotSeparator() val patch = number() - val metadata = metadata() + val snapshot = checkPresent(snapshot) SemVer( major = major, minor = minor, patch = patch, - snapshot = metadata == "SNAPSHOT", + snapshot = snapshot, ) } }