
Введение
GitHub Actions (GHA) — это отличный инструмент для настройки CI/CD для тех, кто пользуется GitHub. Существует и GitHub Marketplace, где можно найти тысячи готовых GHA под любые задачи. Но всегда найдётся процесс, который захочется настроить под себя — тогда нам придётся написать кастомный GHA.
В этой статье я покажу, как создать свой GHA на Kotlin/JS, используя плагин Kotlin Multiplatform. Особенно это будет интересно Android-разработчикам, ведь Kotlin — это наш родной язык, и мы можем применять уже имеющиеся навыки для написания GHA, не углубляясь в JavaScript.
В конце статьи вас ждёт готовый шаблон в GitHub. С его помощью вы сможете сразу приступить к написанию GHA на Kotlin/JS, минуя написание бойлерплейт-кода. О нём я и расскажу в этой статье.
Погнали!
GitHub Actions
GitHub Actions — это встроенный в GitHub инструмент, который даёт возможность настраивать CI/CD процессы. Например, когда в репозитории происходит событие, коммит или pull request, GHA автоматически запускает нужные скрипты. Одним словом — это наш инструмент для CI/CD.
В GHA всё начинается с Workflow — именно его GitHub Actions выполняет в ответ на определённые события. Каждый Workflow состоит из одного или нескольких jobs, которые могут выполняться параллельно. А внутри каждого job есть steps, выполняющиеся последовательно.
Step может быть обычным shell-скриптом или отдельным Action. Последние представляют собой «строительные блоки» GitHub Actions. Они позволяют переиспользовать и комбинировать готовые решения.

Иногда нам нужно написать собственный Action, чтобы реализовать особый сценарий. Например, в моём случае нужно было вычислять Commit Lead Time. У вас это могут быть другие задачи: особая логика управления версиями, загрузка сборок в специфичные хранилища и т.д. Чтобы создать кастомный GitHub Action, мы можем выбрать один из трёх вариантов:
Composite Action — это комбинация нескольких команд и других actions, объединённых в одном месте. Как Compound View, а не Custom View в мире Android-разработки;
Docker Action — это Action, упакованный в Docker-контейнер. В таком случае вы можете выбрать любой язык. Если ваш любимый язык Go, пишите на Go. Здесь главный недостаток в том, что контейнер должен работать на Linux. А значит он не подойдёт, если вам нужно будет что-то запустить на macOS (например, инструменты для iOS);
JavaScript Action — это Action, который запускается в среде Node.js, причём быстрее, чем Docker, потому что Node.js уже работает в раннерах, и не надо разворачивать контейнер. Если вы знакомы с экосистемой Node.js, этот вариант отлично вам подойдёт.
Напишем простейший GHA
Чтобы написать GitHub Action на Kotlin/JS, нужно понимать, как создавать кастомные экшены на обычном JavaScript. Если вы знаете, как писать GitHub Actions на JS, смело переходите к следующему разделу статьи.
Также я добавлю дисклеймер. Я в первую очередь Android-разработчик и большую часть времени работаю с Kotlin/Java. В JavaScript и его экосистеме я не считаю себя экспертом, поэтому в статье мо��ут встречаться вещи, которые покажутся очевидными или даже баянистыми для опытных JS-разработчиков. Прошу отнестись с пониманием: моя цель — помочь другим Android-разработчикам и всем, кто привык к Kotlin, открыть возможность создавать кастомные GitHub Actions с помощью Kotlin/JS.
Нам потребуются знания таких слов, как Nodejs, npm и ncc. Я не буду вдаваться в их подробное описание, лишь кратко перечислю:
Node.js — это среда выполнения JavaScript-кода, которая позволяет запускать его не только в браузере, но и в любом другом окружении;
npm — менеджер пакетов в мире Node.js;
ncc — один из популярных инструментов (бандлеров), который упаковывает JS-код в единый файл.
На схеме ниже изображён GitHub Action на JS, но по сути это обычное приложение, которое работает в среде Node.js.

Точка входа в GHA — файл action.yml. Здесь описываются метаданные Action: имя, описание, входные и выходные данные и т.д.
Напишем простой action.yml:
name: 'Roll a dice' description: 'Simple Github Action for roll a dice' inputs: number-of-sides: description: 'How many sides the dice has' required: true default: '6' outputs: concat: description: 'Result of rolling the dice' runs: using: 'node20' main: 'index.js'
Обратите внимание, что мы указали, где будет лежать сам исполняемый файл main: 'index.js'. Это означает, что исполняемый код будет лежать в текущей директории в файле index.js.
Теперь нам нужен Workflow, в рамках которого мы запустим и протестируем наш Action. Здесь стоит обратить внимание, что первым шагом (step) мы вызываем Action actions/checkout@v4. Он нужен, чтобы получить исходный код, в котором и будет написана сама логика экшена. Второй шаг — выполнение нашего экшена uses: ./, потому что action.yml лежит в текущей директории. Третий шаг — вывод результата.
on: [push] jobs: roll_the_dice_job: runs-on: ubuntu-latest name: Roll the dice steps: - name: Checkout uses: actions/checkout@v4 - name: Roll the dice step id: roll uses: ./ with: number-of-sides: '12' - name: Show the result step run: echo "The die rolled at ${{ steps.roll.outputs.result }}"
Теперь можно написать сам action в файле index.js.
const core = require('@actions/core'); try { const sides = core.getInput('number-of-sides'); console.log(`Start rolling a dice with ${sides}!`); const result = rollDice(sides) core.setOutput("result", result); } catch (error) { core.setFailed(error.message); } function rollDice(sides) { const numberOfSides = parseInt(sides, 10); ... return Math.floor(Math.random() * numberOfSides) + 1; }
Этот action «кидает кубик» и показывает, какое число выпало. Протестировать наш action можно двумя путями:
запушить код в Github и запустить workflow;
запустить локально через инструмент act (https://nektosact.com/).
Последний штрих — это добавление ncc. Как вы могли заметить, в Node.js много библиотек и все они складываются в папке node_modules. Чтобы не таскать с собой эту кучу файлов, лучше упаковать всё в один исполняемый файл, где будет только то, что надо. Это можно сделать через паккер nnc. Это не единственный инструмент — есть и другие. Например, webpack, о котором мы позже поговорим, но сам GitHub в своей документации рекомендует именно ncc.
Запускаем ncc:
npm i -g @vercel/ncc ncc build index.js --license licenses.txt
И получаем итоговую схему работы:

Создание простого Kotlin/JS Action
Как нам здесь поможет Kotlin/JS? Всё просто. Kotlin-код может компилироваться под разные платформы или таргеты: в байт-код Java (для JVM), в бинарные файлы под нативные платформы (.so, .a, .framework) и в JavaScript (JS). Благодаря этому мы можем использовать Kotlin для разработки приложения, которое в итоге будет работать в среде Node.js. Т.е. мы пишем на Kotlin, компилятор генерирует JS-код, и всё это запускается на Node.js.

Когда Gradle с плагином Kotlin/JS или Multiplatform скомпилирует наш код, он автоматически сгенерирует JavaScript и сформирует файл package.json, где пропишет все необходимые зависимос��и npm. Мы получим привычный GitHub Action, написанный на JS.

В Gradle необходимо подключить Kotlin-плагин для JavaScript. Существует два варианта:
kotlin("js").kotlin("multiplatform").
По факту эти подходы не отличаются друг от друга — они оба используют одни и те же модули под капотом. Тем не менее в официальной документации JetBrains рекомендуется использовать именно kotlin("multiplatform"). Видимо, это более гибкий и масштабируемый подход, если в дальнейшем вы будете делать сборку и под JVM, и под Native.
plugins { kotlin("js") version "2.0.0" } или plugins { kotlin("multiplatform") version "2.0.0" }
Конфигурируем наш Kotlin плагин. Следующий шаг — конфигурация модуля для компиляции JS-кода. Ниже показано, как это обычно делается в файле build.gradle.kts, если вы используете Kotlin Multiplatform или Kotlin JS.
kotlin { js(IR) { nodejs { binaries.executable() } } sourceSets { val jsMain by getting { dependencies { } } } }
Здесь мы указываем, что используем IR-бэкенд для Kotlin/JS (это более современный режим компилятора), и настраиваем таргет nodejs, чтобы итоговый код можно было запустить в Node.js.
Файлы action.yml и workflow main.yml при этом не меняются — структура GitHub Action остаётся прежней.
Чтобы использовать любую npm-библиотеку (например, @actions/core) в проекте на Kotlin/JS, мы указываем её в секции зависимостей через метод npm в Gradle. Всё, что вы укажете здесь, будет записано в сгенерированный package.json, а пот��м скачано в папку node_modules.
В мире GitHub Actions есть официальный набор инструментов GitHub Actions Toolkit, а @actions/core — один из его ключевых пакетов. Он предоставляет функции и утилиты, упрощающие взаимодействие с GitHub Actions: чтение входных параметров (inputs), выставление выходных (outputs), логирование и т.д.
sourceSets { val jsMain by getting { dependencies { implementation(npm("@actions/core", "1.4.0")) } } }
Чтобы воспользоваться методами из @actions/core в Kotlin/JS-коде, мы объявляем в отдельном файле внешние функции с помощью аннотации @file:JsModule("@actions/core"). Ключевое слово external указывает на то, что данные функции не реализованы на Kotlin напрямую, а подключаются из внешнего JS-кода.
@file:JsModule("@actions/core") package com.example.utils.actions external fun setOutput(name: String, value: Any) external fun setFailed(message: String)
Подключив и описав методы из @actions/core, мы можем приступить к написанию собственного GitHub Action на Kotlin/JS. Используем функции setOutput и setFailed, чтобы работать с результатами и ошибками внутри экшена.
import com.example.utils.actions.* suspend fun main() { setOutput("failed", false) try { val result = "Custom String! Congratulations!" setOutput("result", result) print(result) } catch (ex: Exception) { setFailed("Error while performing GHA") } }
Обратите внимание, что функция main помечена как suspend. Это необязательно, но даёт возможность при необходимости использовать корутины или асинхронные вызовы.
Вызываем ./gradlew build и смотрим, что скомпилировал нам плагин Kotlin Multiplatform. Команда скомпилирует ваш Kotlin-код в JavaScript и добавит все необходимые зависимости в node_modules.

После сборки удобно свести все зависимости и исходный JS-код в один исполняемый файл. Для этого используем ncc. Ниже описаны шаги, как получить итоговый файл через ncc:
найти скомпилированные файлы
./build/js/packages/test-gha-1/kotlin;Выполнить ncc:
ncc build test-gha-1.js --license licenses.txt;Получить результат в:
./build/js/packages/test-gha-1/kotlin/dist;Скопировать оттуда в:
./dist.
Чтобы это не писать руками, можем всё сделать в Gradle-таске:
tasks.register<Exec>("buildAndPackWithInstalledNCC") { dependsOn("build") commandLine("npx", "ncc", "build", "${layout.buildDirectory.get()}/js/packages/${project.name} /kotlin/${project.name}.js", "--license", "licenses.txt", "-o", "dist") }
Такой вариант требует предустановки ncc. Для удобства мы можем установить ncc автоматически через Gradle, указав его в разделе devNpm. Тогда Gradle сам добавит утилиту в package.json в разделе devDependencies.
sourceSets { val jsMain by getting { dependencies { implementation(npm("@actions/core", "1.4.0")) implementation(devNpm("@vercel/ncc", "0.38.1")) } } }
Полученный package.json будет выглядеть примерно так:
{ ... "devDependencies": { "@vercel/ncc": "0.38.1", "typescript": "5.4.3", "source-map-support": "0.5.21" }, "dependencies": { ... }, }
Чтобы автоматизировать процесс, создадим две задачи в Gradle: installNodeModules и buildAndInstallAndPackWithNCC. Первая установит нужные пакеты, а вторая скомпилирует KotlinJS-код и запустит ncc:
tasks.register<Exec>("installNodeModules") { dependsOn("build") workingDir = file("${layout.buildDirectory.get()}/js/packages/${project.name}/") commandLine("npm", "install") } tasks.register<Exec>("buildAndInstallAndPackWithNCC") { dependsOn("installNodeModules") workingDir = file("${layout.buildDirectory.get()}/js/packages/${project.name}/") commandLine("npx", "ncc", "build", "./kotlin/${project.name}.js", "--license", "licenses.txt", "-o", "${layout.projectDirectory}/dist") }
Сделаем круче — воспользуемся API NCC
NCC можно использовать как библиотеку и напрямую обращаться к функциям через Kotlin, а не запускать его из командной строки. Для этого создадим отдельный Gradle-модуль, где установим в зависимости @vercel/ncc. Обратите внимание: здесь уже не devNpm, а просто npm.
plugins { kotlin("multiplatform") version "2.0.0" } kotlin { // all the same... } dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0") implementation("org.jetbrains.kotlinx:kotlinx-nodejs:0.0.7") implementation("org.jetbrains.kotlin-wrappers:kotlin-js:1.0.0-pre.785") implementation(npm("@vercel/ncc", "0.38.1", generateExternals = false)) }
Опишем внешний интерфейс для библиотеки @vercel/ncc, чтобы вызывать её функции:
@JsModule("@vercel/ncc") external fun ncc(input: String, options: NccOptions = definedExternally): Promise<NccResult> external interface NccResult { val code: String val map: String? val assets: AssetMap? } external interface NccOptions { var cache: dynamic var externals: List<String> ... }
Главный метод ncc возвращает Promise с объектом, содержащим объединённый код, карту исходников (sourceMap) и другие вспомогательные данные.
val nccResult = ncc( input = inputPath, options = jsObject { sourceMap = true license = "LICENSES" } ).await() external interface NccResult { val code: String val map: String? val assets: AssetMap? }
Далее пишем функцию main, где вызываем ncc и обрабатываем его результат:
suspend fun main() { runCatching { val (inputPath, outputPath) = readArgs(process.argv) val combinedCode = combineCode( inputPath = inputPath, outputPath = outputPath, fileName = "index.js" ) createOutputFolder(outputPath = outputPath) with(combinedCode) { copyCode() copyMapping() copyAssets() } }.onFailure { throwable -> console.error(throwable) process.exit(1) } }
Чтобы всё это запустить, передаём в Gradle-таске jsNodeProductionRun пути к файлам через метод args:
tasks.named<NodeJsExec>("jsNodeProductionRun") { val inputPath = "${rootProject.layout.buildDirectory.get()}/js/packages/${rootProject.name}/" val outputPath = "${rootProject.layout.projectDirectory}/dist/" args(inputPath, outputPath) }
И запускаем:
./gradlew build :ncc:jsNodeProductionRun
Теперь у нас есть полноценный модуль, который программно вызывает ncc API, комбинирует весь JS-код и зависимости. На схеме это выглядит так:

Webpack
Webpack — это один из наиболее популярных бандлеров JavaScript-приложений. В нашем случае функции Webpack и ncc идентичны. Webpack берёт скомпилированный JS-код, включая все зависимости, и собирает в единый файл.
Однако при использовании ncc я столкнулся с ошибкой, связанной с модулем abort-controller. Ошибка выглядит так:
https://api.github.com/repos/[...]/[...]/git/refs/tags failed with exception: Error: Cannot find module 'abort-controller' | Require stack: | - ./dist/index.js
Как оказалось, это известная проблема, на которую есть открытая задача в трекере KTOR-405. Поэтому пока мы вынуждены пользоваться хаком, чтобы устранить эту ошибку вручную.
Ниже показан пример, как можно руками подправить конфиг Webpack, чтобы подменить пути к проблемным пакетам:
private fun WebpackInputParams.toWebpackConfig(): WebpackConfig { return WebpackConfig( projectName = name, inputFilePath = "$buildDir/js/packages/$name/kotlin/$name.js", outputDirPath = outputDir, outputFileName = "index.js", modules = listOf(...), aliases = mapOf( "node-fetch$" to "node-fetch/lib/index.js", "abort-controller$" to "abort-controller/dist/abort-controller.js", ) ) } ... if (content.contains("eval('require')")) { val fixedContent = content.replace("eval('require')", "require") writeFileSync(path, fixedContent) }
Здесь мы в явном виде переопределяем пути для node-fetch и abort-controller, а также устраняем вызов eval('require'), чтобы всё корректно работало при сборке. Это не самое изящное решение, но что поделать.
Если вы знаете лучший способ обойти эту ошибку, обязательно напишите в комментариях. Буду признателен!
Таким образом, у нас есть как минимум два способа собрать результат в один JS-файл:
./gradlew build :ncc:jsNodeProductionRun;./gradlew build :webpack:jsNodeProductionRun.
Обе команды выполняют одну и ту же задачу, но разными инструментами. Ncc показался мне проще в настройке, однако Webpack смог исправить баг.
Template
В этой статье мы разобрали много мелких деталей. Может показаться, что написать кастомный GitHub Action на Kotlin/JS сложно. Слишком много различных шагов.
Чтобы упростить вам и себе задачу, я подготовил шаблон на GitHub. В этом репозитории уже настроены все необходимые инструменты: плагин, зависимости, сборка в единый файл через ncc и Webpack и так далее. Если захотите что-то доработать или улучшить, смело присылайте Pull Request!
https://github.com/makzimi/kotlin-github-action
Структура main метода шаблона похожа на то, что я описывал в статье:
Читаем входные параметры.
Запускаем основную бизнес-логику.
Устанавливаем результат или ошибку.
suspend fun main() { val input = buildGHAInput() try { group("Action body") { val output = runAction(input = input) if (output.success) { setOutput(Result.value, output.result) } else { setFailed(output.errorText.orEmpty()) } } } catch (e: Exception) { setFailed("Error while performing GitHub Action: ${e.message}") } }
Благодаря этому шаблону вам не придётся возиться с подготовительным кодом. Вы сможете сразу приступить к написанию собственной логики экшена.
Выводы
В GitHub Marketplace действительно много готовых экшенов, но далеко не все из них подойдут именно вам. У меня возник кейс, для которого мне понадобился кастомный GitHub Action.
Docker-вариант на первый взгляд кажется удобнее, особенно если вы не планируете писать на Kotlin.
Но если вы хотите применить Kotlin для CI/CD, то это отличный способ использовать уже знакомый язык на новую задачу. Написание GitHub Action на Kotlin/JS не будет слишком сложной задачей, особенно если пользоваться шаблоном и не писать бойлерплейт-код.
Круто, что Kotlin позволяет компилировать код под разные платформы. Сегодня вы пишете Android-приложение, а завтра точно так же на Kotlin создаёте GitHub Action.
Если у вас остались вопросы или идеи, как улучшить процесс, обязательно делитесь ими в комментариях или открывайте Pull Request к шаблону!
Спасибо, что дочитали статью! Если вам интересен мой опыт, но лень читать большие тексты, подписывайтесь на Telegram-канал «Мобильное чтиво». Там я в формате постов делюсь своими мыслями про Android-разработку и не только.
О том, как мы развиваем IT в Додо в целом, читайте в Telegram-канале Dodo Engineering. Там мы рассказываем о нашей жизни, культуре и последних разработках.
