KotlinJS в GitHub Actions

от автора

Введение

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Писали ли вы кастомный GitHub Action?

33.33% Да, но только Composite1
0% Да, пробовал Docker-вариант0
66.67% Да, на JavaScript2
0% Да, на Kotlin/JS0
0% Не писал0

Проголосовали 3 пользователя. Воздержавшихся нет.

ссылка на оригинал статьи https://habr.com/ru/articles/898704/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *