Pull to refresh
Dodo Engineering
О том, как разработчики строят IT в Dodo

KotlinJS в GitHub Actions

Level of difficultyMedium
Reading time11 min
Views1.2K

Введение

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. Они позволяют переиспользовать и комбинировать готовые решения.

GHA-structure.png

Иногда нам нужно написать собственный 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-01-js.png

Точка входа в 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

И получаем итоговую схему работы:

GHA-02-js-ncc.png

Создание простого Kotlin/JS Action

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

Kotlin-complies.png
Kotlin-complies.png

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

GHA-03-kotlinjs.png
GHA-03-kotlinjs.png

В Gradle необходимо подключить Kotlin-плагин для JavaScript. Существует два варианта:

  1. kotlin("js").

  2. 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.

https://lh7-rt.googleusercontent.com/slidesz/AGV_vUcs887W5-t--sSk8T16fyd-Z4IG881A6-dDuQezC43JRUSpnuMzor0USUJcXatlO3xGpfyjMAuL8oY6nv-f4dqvl7VaQz2XEt8kGgDzFok_dbIFwkLIOX_-lls2HRQnJzevX4E9=s2048?key=7IPPKxiF8IdFIIvRE0IVS9DT

После сборки удобно свести все зависимости и исходный 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-код и зависимости. На схеме это выглядит так:

GHA-04-kotlinjs.png

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 метода шаблона похожа на то, что я описывал в статье:

  1. Читаем входные параметры.

  2. Запускаем основную бизнес-логику.

  3. Устанавливаем результат или ошибку.

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

Only registered users can participate in poll. Log in, please.
Писали ли вы кастомный GitHub Action?
33.33% Да, но только Composite1
0% Да, пробовал Docker-вариант0
66.67% Да, на JavaScript2
0% Да, на Kotlin/JS0
0% Не писал0
3 users voted. 1 user abstained.
Tags:
Hubs:
Total votes 4: ↑4 and ↓0+6
Comments0

Articles

Information

Website
dodoengineering.ru
Registered
Founded
Employees
201–500 employees
Location
Россия