
Введение
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. Там мы рассказываем о нашей жизни, культуре и последних разработках.