Automating Deletion of GitHub Packages with Kotlin Script

Vita Sokolova
Kt. Academy
Published in
7 min readFeb 11, 2024

--

TL;DR

This article guides you through writing a script in Kotlin, which makes API calls to the GitHub API to fetch and delete certain GitHub Package versions and how to launch this script on CI. Find the full code on this GitHub repository.

At Temper, we built a Kotlin Multiplatform library to share code across various front-end platforms. We use KMMBridge’ standard GitHub Action to publish a new version of our library to GitHub Packages after merging any pull request. To optimize hosting costs and avoid cluttering the repository with not-used outdated packages, we decided to automate the cleanup process.

Our goal is to retain:

  • release versions,
  • versions not older than 30 days,
  • 5 latest versions.

GitHub suggests using an official delete-package-versions action for that purpose. Unfortunately, it has dozens of open issues and the functionality of deleting only pre-release package is completely broken right now. Also, it can’t delete packages older than a certain period of time.

So, I started checking the GitHub API to figure out how to delete packages without that action and I found all we needed.

GitHub’s API

This is a list of endpoints we are going to use in our script:

To pack all those calls into a GitHub Workflow, we could try and figure out how to write a bash script that does that, including filtering, mapping and sorting... Instead, as a Kotlin developer, I decided to use the language I’m the most comfortable with and can extend and maintain. Let me introduce Kotlin Script to you!

Kotlin scripting is the technology that enables executing Kotlin code as scripts without prior compilation or packaging into executables.

You can find more information about this technology in Kotlin Docs. For seeing how to use Kotlin Script in practice, I’d suggest watching a great talk by Eugen Martynov, he presented at Droidcon Berlin.

Writing the script

Now let’s start writing our script, which will:

  1. Fetch all releases for a repository
  2. Fetch all packages for a repository
  3. Filter the versions to clean
  4. Delete unwanted versions

To create a project, I used JetBrains’ manual: Get started with Kotlin custom scripting — tutorial. In the end you will have a project in IntelliJ IDEA with html.scriptwithdeps.ktsfile in it. Delete it and create acleaner.main.kts file instead. Change file extension in ScriptWithMavenDeps.kt .

@KotlinScript(
// HERE ↓↓↓
fileExtension = "cleaner.main.kts",
compilationConfiguration = ScriptWithMavenDepsConfiguration::class
)
abstract class ScriptWithMavenDeps

Now we can use IDEA to run our script and have all the benefits of IDEA like autocomplete.

Actually, we could write our script just in a notepad and launch it from a command line, but if you are not a fan of red imports, having the script in an IDEA project is more pleasant for your eyes.

Let’s check that our setup works and run a Hello World. Place this code in cleaner.main.kts

@file:Repository("https://repo1.maven.org/maven2/")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")

import kotlin.system.exitProcess
import kotlinx.coroutines.runBlocking

runBlocking {
println("Hello world!")
exitProcess(0)
}

To run it from a terminal, use this command:

kotlinc -script cleaner.main.kts

⚠️ To run this command, you may need to install a Kotlin command-line compiler first as described here.

Step 1: Fetching Releases

To perform network calls we will use Fuel. Just below our runBlocking{} function let’s create a class to perform all our future API calls:

@file:DependsOn("com.github.kittinunf.fuel:fuel-jvm:3.0.0-alpha1")
@file:DependsOn("com.google.code.gson:gson:2.10.1")

import com.google.gson.JsonParser
import fuel.Fuel
import fuel.Request
...

class GitHubRepository(
private val token: String,
private val organisation: String,
private val repositoryName: String
) {

suspend fun getReleases(): List<String> {
val url = "https://api.github.com/repos/$organisation/$repositoryName/releases"
val response = Fuel.loader().get(
Request.Builder().apply {
headers(buildHeaders())
url(url)
}.build()
)

return if (response.statusCode == 200) {
JsonParser.parseString(response.body).asJsonArray.map {
it.asJsonObject.get("tag_name").asString
}
} else {
throw Exception("GET $url ended with exception:\nstatus code: ${response.statusCode}\n${response.body}")
}
}

private fun buildHeaders(): Map<String, String> {
return mapOf(
"Accept" to "application/vnd.github+json",
"Authorization" to "Bearer $token",
"X-GitHub-Api-Version" to "2022-11-28"
)
}
}

Now let’s run this new code from the “main” function.

runBlocking {
val token = "ghp_xxx"
val organisation = "SampleOrg"
val repositoryName = "frontend-shared"

val gitHubRepository = GitHubRepository(token, organisation, repositoryName)

val releases = gitHubRepository.getReleases()

println("Releases found in $repositoryName repository:")
println(releases.joinToString(", ", "[", "]"))

exitProcess(0)
}

If you run the script, you will see the list of releases found in your repository.

Step 2: Fetching Packages

Let’s create a class describing the package. I write all code in one file, just one class after another.

class Package(
val id: String,
val name: String,
val type: String,
val repository: String
)

Now we can expand GitHubRepository to include a method for fetching packages.

suspend fun getPackages(
packageType: String
): List<Package> {
val url = "https://api.github.com/orgs/$organisation/packages?package_type=$packageType"
val response = Fuel.loader().get(
Request.Builder().apply {
headers(buildHeaders())
url(url)
}.build()
)

if (response.statusCode == 200) {
val packagesResponseJson = JsonParser.parseString(response.body)
val packages = packagesResponseJson.asJsonArray.map {
Package(
id = it.asJsonObject.get("id").asString,
name = it.asJsonObject.get("name").asString,
type = it.asJsonObject.get("package_type").asString,
repository = it.asJsonObject.get("repository").asJsonObject.get("name").asString
)
}
return packages.filter {
it.repository == repositoryName
}
} else {
throw Exception("GET $url ended with exception:\nstatus code: ${response.statusCode} \n${response.body}")
}
}

After that, we can call getPackages() from the main function and print the list of packages found for our repository:

runBlocking {

...

val packages = gitHubRepository.getPackages("maven")
println("Packages found in $repositoryName repository:")
println(packages.joinToString(", ", "[", "]") { it.name })

exitProcess(0)
}

Step 3: Fetching and filtering versions

Create a class Version to represent package versions.

class Version(
val id: String,
val name: String,
val createdAt: LocalDateTime
)

And describe one more API call in GitHubRepository to fetch all versions for a specific package:

suspend fun getAllVersionsOfPackage(
packageName: String,
packageType: String
): List<Version> {
val url = "https://api.github.com/orgs/$organisation/packages/$packageType/$packageName/versions"
val response = Fuel.loader().get(
Request.Builder().apply {
headers(buildHeaders())
url(url)
}.build()
)

if (response.statusCode == 200) {
val responseJson = JsonParser.parseString(response.body)
val versions = responseJson.asJsonArray.map {
Version(
id = it.asJsonObject.get("id").asString,
name = it.asJsonObject.get("name").asString,
createdAt = LocalDateTime.parse(
it.asJsonObject.get("created_at").asString,
DateTimeFormatter.ISO_OFFSET_DATE_TIME
)
)
}
return versions
} else {
throw Exception("GET $url ended with exception:\nstatus code: ${response.statusCode} \n${response.body}")
}
}

Now we will make this call for every package we received in the previous step:

runBlocking {

..

packages.map { githubPackage ->
async {
val allVersions = gitHubRepository.getAllVersionsOfPackage(
packageName = githubPackage.name,
packageType = githubPackage.type
)

println("Versions found for ${githubPackage.name}:")
println(allVersions.joinToString(", ", "[", "]") { it.name })
}
}.awaitAll()

exitProcess(0)
}

It’s time to apply some filtering! In this step you can apply any logic you find reasonable to determine which versions to keep and which to delete. In my case, I will keep a version if it is not in the list of releases, not older than 30 days and not one of the 5 latest. We add this filtering to the mapping above:

val minVersionsToKeep = 5
val packageMaxLifetime = Period.ofDays(30)
...
async {
val allVersions = gitHubRepository.getAllVersionsOfPackage(
packageName = githubPackage.name,
packageType = githubPackage.type
)

// Filtering logic
val expiredVersions = allVersions
.sortedByDescending { it.createdAt }
.takeLast((allVersions.size - minVersionsToKeep).coerceAtLeast(0))
.filter {
it.createdAt.isBefore(
LocalDateTime.now().minus(packageMaxLifetime)
)
}
val versionsToDelete = expiredVersions.filter { !releases.contains(it.name) }

println("Versions found for ${githubPackage.name}:")
println(allVersions.joinToString(", ", "[", "]") { it.name })
println("Will be deleted:")
println(versionsToDelete.joinToString(", ", "[", "]") { it.name })
}

Now we can execute a dry run and see what will be deleted.

Step 4: Deleting Versions

The final extension for GitHubRepository:

suspend fun deletePackageVersion(
packageType: String,
packageName: String,
packageVersionId: String
) {
val url = "https://api.github.com/orgs/$organisation/packages/$packageType/$packageName/versions/$packageVersionId"
val response = Fuel.loader().delete(
Request.Builder().apply {
headers(buildHeaders())
url(url)
}.build()
)
if (response.statusCode != 200) {
throw Exception("DELETE $url ended with exception:\nstatus code: ${response.statusCode} \n${response.body}")
}
}

Now instead of printing the version, which would be deleted, let’s call deletePackageVersion() function:

if (dryRun) {
println("DRY RUN: would be deleted:")
println(versionsToDelete.joinToString(", ", "[", "]") { it.name })
} else {
versionsToDelete.map { version ->
async {
gitHubRepository.deletePackageVersion(
packageType = githubPackage.type,
packageName = githubPackage.name,
packageVersionId = version.id
)
println("${githubPackage.name} ${version.name} DELETED")
}
}.awaitAll()
}

Optional step: Adding input parameters

You might notice that inside runBlocking{} we have declared a few parameters, in this last step we will make our script reusable by accepting them as input parameters.

runBlocking {
val token = args[0]
val organisation = args[1]
val repositoryName = args[2]
val minVersionsToKeep = args[3].toInt()
val packageMaxLifetime = Period.ofDays(args[4].toInt())
val dryRun = args.getOrNull(5)?.toBoolean() ?: false

...
}

And now we can launch our script with this command:

kotlinc -script cleaner.main.kts "ghp_xxx" "organisation" "repository" 5 30 true

There are rumors that it’s possible to make arguments named with Kotlin Script, but I spent 30 minutes searching and didn’t find how to do it. If you, dear reader, know more about this topic, please share in the comments.

Final step: Packing the script into a GitHub Workflow

  1. Copy your script to a project you want to launch cleaning for. I put in in scripts/cleaner.main.kts.
  2. Save a token with access to this repository’s packages into GitHub Secrets (I called it TOKEN).
  3. Copy the workflow below into ./github->workflows folder in your project.
name: Monthly Package Cleanup
on:
schedule:
- cron: '0 8 1 * *' # Run on the 1st date of every month at 8 a.m. (UTC)
workflow_dispatch:

jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
TOKEN: ${{ secrets.TOKEN }}
ORGANISATION: “***”
REPOSITORY: “***”
MAX_LIFETIME_IN_DAYS: 30
MIN_VERSIONS_TO_KEEP: 5
DRY_RUN: "false"
steps:
- uses: actions/checkout@v2
- name: Run Kotlin script
run: kotlinc -script scripts/cleaner.main.kts ${{ env.TOKEN }} ${{ env.ORGANISATION }} ${{ env.REPOSITORY }} ${{ env.MAX_LIFETIME_IN_DAYS }} ${{ env.MIN_VERSIONS_TO_KEEP }} ${{ env.DRY_RUN }}

Kotlin is pre-installed on all standard GitHub Actions runners, so there is no need to install it beforehand.

Conclusion

In this article, we’ve created a Kotlin script to interact with the GitHub API and delete specific package versions.

This script offers advantages over an official GitHub Action, including:

  1. custom filter logic (in our case, deleting packages older than X days),
  2. support for dry runs,
  3. code on Kotlin, which is easier modify for mobile developers.

While not the most optimized script, it serves well if you are a mobile team and you need to maintain and extend this logic. Using Kotlin Scripts for automations may save you a lot of time and nerves.

You can find the full version of the script in this GitHub repository. Happy coding!

--

--