CI/CD для low-code: как мы вытащили ELMA 365 из браузера в репозиторий

Low-code обещает, что программировать почти не придётся. Первые полгода так и есть. Потом у вас десяток модулей, полсотни форм и человек, который вчера «чуть-чуть поправил скрипт на проде». И выясняется, что это всё-таки разработка. Просто без git, без ревью и без кнопки «откатить».
Я руковожу направлением бизнес-приложений в холдинге. Мы с командой и ИИ-агентами строим на ELMA 365 модули для договоров, контрагентов, корреспонденции, доверенностей и облигаций. Это второй текст из серии про ИИ в такой разработке. Сегодня про конвейер: как код виджетов переехал из окна браузера в репозиторий, и на какие грабли мы наступили по дороге.
Спойлер: путь есть. Но он не там, где его ищут.
С чего всё началось
В ELMA 365 почти всё настраивается в веб-дизайнере. Формы, поля, процессы, права. У виджетов есть скрипты на TypeScript: клиентская часть работает в браузере пользователя, серверная на стороне платформы. Пишутся они в редакторе прямо внутри дизайнера. Нажал «Проверить», нажал «Опубликовать», готово.
Для одного разработчика это удобно. Для команды, где часть кода пишут ИИ-агенты, это тупик. Агенту нужен файл. Ревьюеру нужен дифф. Руководителю нужен ответ на вопрос «что именно поменялось во вторник».
У платформы есть утилита командной строки для экспорта и импорта решений. Экспорт работает отлично. Выгружает всё: формы, поля, права, процессы, скрипты. Сотни файлов, которые прекрасно ложатся в git.
С обратной дорогой хуже. Пока мы правили формы и подписи, импорт проходил. Стоило тронуть скрипт виджета, и загрузка обратно падала с невнятной ошибкой. Мы воспроизвели это на нескольких сборках платформы, от старых до свежих. Перерыли официальную документацию. Нашли единственный публичный пример использования утилиты в конвейере, и тот на закрытом скрипте.
Вывод был неприятный. Официальная позиция простая: скрипты виджетов пишутся в дизайнере. Документированного пути в обход него нет. Для переноса между средами у вендора есть отдельный механизм, который передаёт объекты напрямую с сервера на сервер. Но он про перенос, а не про то, чтобы код жил в репозитории.
Можно было смириться. Мы решили сначала разобраться, что на самом деле происходит за кнопкой.
Временный костыль: робот в дизайнере
Пока искали нормальный путь, работу надо было делать. Поэтому первое решение было честно костыльным.
Мы научили безголовый браузер ходить в дизайнер вместо человека. Скрипт логинится, открывает нужный виджет, вставляет код в редактор, нажимает «Сохранить», «Проверить», «Опубликовать». Потом читает результат и сверяет, что опубликовалось именно то, что хотели.
Звучит просто. На деле каждый шаг стоил отдельного вечера. Редактор кода сам закрывает скобки, поэтому печатать код посимвольно нельзя: получается каша из лишних скобок и кавычек. Вставлять приходится через буфер обмена. Проверять вставку по скрытому текстовому полю тоже нельзя: в нём лежит только видимый кусок кода, а не весь файл.
А самый неприятный сюрприз ждал нас после обновления платформы. В новой версии кнопка «Опубликовать» оставалась активной, даже если «Проверить» нашла ошибки компиляции. Робот, который смотрел на состояние кнопки, мог бы опубликовать сломанный код. Мы переделали его: теперь он читает ответ компилятора, а не внешний вид интерфейса.
Из этого периода у нас осталось правило, которое я считаю главным во всей истории. Автоматизация интерфейса допустима только там, где нет командной строки или API. Браузер — последний вариант, а не первый. Он хрупкий, медленный и ломается от каждого редизайна. Если появляется нормальный путь, робот уходит на пенсию.
Что на самом деле делает кнопка «Проверить»
Дальше мы пошли смотреть, что происходит на сервере, когда человек нажимает «Проверить» или «Опубликовать». Разбираемся.
Кнопка отправляет виджет в сервис виджетов. Тот кладёт задачу «скомпилировать» в очередь сообщений (это RabbitMQ). Задачу забирает рабочий сервис на Node.js, внутри которого живёт обычный компилятор TypeScript.
Самое интересное происходит до компиляции. Для каждого виджета сервер генерирует файл описания типов. В нём объявлено всё, что доступно скрипту: контекст формы с её полями, поля приложения, к которому привязана форма, серверные функции, платформенный SDK. Поэтому компилятор знает, что у вашего договора есть поле «Контрагент», а у формы нет поля «Контрагентт» с двумя «т».
Дальше всё как в обычном проекте. Компилятор собирает программу из двух файлов: описание типов и ваш скрипт. Если есть хоть одна ошибка, скрипт отклоняется и ничего не сохраняется. Если ошибок нет, код транспилируется.
Клиентский код при этом заворачивается в функцию-обёртку. Выглядит это примерно так:
export default function (Context, ViewContext, Server, System): any {
// ваш код без import
async function onInit() { /* ... */ }
async function onSave() { /* ... */ }
return { onInit, onSave };
}
Все функции верхнего уровня возвращаются наружу, и платформа может их вызывать. Серверный код транспилируется как есть. Пустой серверный скрипт сохраняется как строка с пустым комментарием. Это прямо помечено в исходниках как костыль, и это меня скорее порадовало: у всех бывают такие места.
И ещё одна деталь, которую мы заметили сразу. Для каждой функции сервер сохраняет описание её параметров. В этих описаниях вместо типов стоит буквальная строка «[object Object]». Это след того, как описание строится из дерева разбора. Ошибка безобидная, но для нас важная: если хотите повторить компилятор байт в байт, повторять придётся и такие особенности.
Компилятор у себя на машине
Раз компилятор обычный, его можно запустить локально. Мы так и сделали.
Описание типов для конкретного виджета берётся тем же запросом, которым его получает сам дизайнер. С обычным входом под своей учётной записью, без вытаскивания токенов из браузера. Компилятор взяли публичный, той же версии, что на сервере, с теми же настройками. Версию зафиксировали жёстко, и отдельный тест падает, если она уедет. Обёртку, порядок сборки и все особенности повторили строка в строку.
Дальше нужно было доказать, что это действительно тот же компилятор, а не похожий.
Мы взяли модуль договоров целиком, все 74 виджета. Прогнали каждый через локальный компилятор. Параллельно отправили те же исходники в серверный «Проверить». Сравнили результат побайтно.
Первый прогон показал ошибки типов в десяти виджетах. Это были не ошибки в коде, а пробелы в нашем понимании, как сервер собирает описание типов. Например, для формы, привязанной к приложению, поля берутся не у формы, а у самого приложения. А сама форма приезжает как дополнительный контекст. Каждое такое правило стоило одного упавшего виджета. Когда правила сошлись, все 74 виджета дали одинаковый результат на нашей стороне и на сервере.
Специально внесённые ошибки тоже совпали. Число вместо строки, вызов несуществующей серверной функции, неверный тип возврата. Обе стороны указывали на ту же позицию в коде.
Были и расхождения. Семь виджетов из стандартной поставки хранили скомпилированный код в старом формате, с обёрткой на три аргумента. Мы отправили их на серверную компиляцию, и сервер выдал ровно наш вариант. Значит, этот код просто собран старой версией платформы.
Нашёлся и один слепой угол самого «Проверить». Серверная проверка строит список серверных функций не из присланного кода, а из того, что уже сохранено. Поэтому новая серверная функция при первой проверке выглядит как ошибка, хотя публикуется и работает. Наш инструмент знает этот случай и объясняет его, а не пугает.
«Подписи нет»
Отдельный вопрос мучил нас с самого начала. Почему пакет с крошечной ручной правкой не загружается обратно? Первая гипотеза была очевидной: пакет подписан, правка ломает подпись.
Кажется логичным. Но нет.
Мы взяли маленькое тестовое решение и поменяли одну букву в заголовке панели: «Основная информация» на «Основная Информация». Пакет с такой правкой проходит серверную проверку без вопросов. Во всём пакете есть только две контрольные суммы. Они защищают флажки «платное» и «заблокировано», чтобы платное решение нельзя было объявить бесплатным. Содержимое форм и скриптов ими не покрыто.
Мы даже поменяли символ в служебном идентификаторе компании внутри пакета. Ничего не изменилось. Проверка прошла, импорт упал точно так же, как без этой правки.
Настоящая причина оказалась в идентичности версий. У каждого решения есть сервер-автор, где оно создано, и серверы, где оно просто установлено. На сервере-установке импорт создаёт виджету новый внутренний идентификатор, и новые версии ложатся чисто. На сервере-авторе импорт находит уже существующий виджет и пытается записать новую версию с идентификатором, который вычисляется из номера версии. А такая запись там уже есть. Получается конфликт, и сервер отказывается.
Итог простой. Сервер-автор не принимает новую версию собственного виджета из файла. Тот же самый пакет спокойно ставится туда, где решение только установлено. Это не защита от подделки. Это правило идентичности, и его надо учитывать в архитектуре сред.
Ловушка тихого успеха
Следующие грабли были неприятнее. Они не падают с ошибкой. Они молча делают вид, что всё хорошо.
В пакете у каждого объекта есть история версий. Если вы изменили объект, но не добавили в историю новую запись, импорт смотрит на неё, видит совпадение с сервером и молча пропускает объект. Завершается при этом успешно. Зелёная галочка, код выхода ноль, ничего не применено.
Предварительная проверка тоже не помогает. Она одинаково довольна пакетом, который применится, пакетом, который молча пропустят, и пакетом, который упадёт на конфликте.
Вторая часть ловушки ещё тоньше. Сервер при импорте не перекомпилирует скрипты. Он берёт скомпилированный код из пакета как есть. Исправили исходник, но не пересобрали? На сервере будет лежать новый текст и работать старый код. Открываете дизайнер, видите исправление, а форма ведёт себя по-старому.
Отсюда два правила, которые теперь стоят в конвейере жёстко.
Первое: после каждой установки делаем повторный экспорт и сравниваем с тем, что отправляли. Успешный выход импорта не считается доказательством. Доказательство — совпадение того, что лежит на сервере, с тем, что мы собирали.
Второе: локальный компилятор умеет работать в режиме проверки без записи. Он берёт выгрузку и сверяет, что скомпилированный код в каждом виджете соответствует исходнику. Если хоть один разошёлся, проверка падает. Это наш барьер в CI против расхождения исходника и того, что реально исполняется.
Обновление от поставщика, которое удалило бы нашу работу
Отдельная история случилась с обновлением от внешнего поставщика. Нам пришла новая сборка модуля договоров, которую надо было установить поверх нашей.
Прежде чем ставить, мы сняли свежую выгрузку живого решения и сравнили формы. В боковой панели карточки договора было одиннадцать блоков. В обновлении тоже одиннадцать, но других. Блока с нашим доработанным виджетом списка документов в обновлении не было вовсе. Установи мы пакет как есть, доработка тихо исчезла бы с формы. Сам виджет остался бы, но его бы никто не вызывал.
Вины поставщика тут нет. Он собирал своё решение, про нашу доработку он не знал. Но вывод с тех пор железный. Перед установкой любого пакета обновления сравниваем живые формы с тем, что приходит. Не надеемся, что обновление только добавляет. Пакет обновления — это не патч, а новая полная версия формы.
Как выглядит конвейер сейчас
Собираю всё вместе. Путь одного изменения в скрипте виджета:
выгрузка решения со стенда
↓
правка исходника .ts в репозитории (человек или агент)
↓
локальная компиляция против типов этого стенда
├─ ошибка типов → ничего не записано, правим
└─ ок → пересобран скомпилированный код, добавлена версия
↓
ревью диффа: исходник + скомпилированный код
↓
установка на тестовый сервер
↓
повторная выгрузка и сравнение с отправленным
↓
коммит того, что реально живёт на сервере
Компилятор при этом можно попросить заодно отправить те же исходники в серверную проверку и упасть, если результат хоть на байт отличается. Перед установкой конвейер сохраняет снимок текущего состояния сервера. Откат тоже идёт через файлы, и после него мы снова выгружаем и сверяем.
Важная деталь про откат. Старый снимок нельзя просто загрузить обратно. Сервер видит, что в нём устаревшая история версий, и отказывается. Поэтому откат берёт содержимое из снимка, а историю версий — с сервера, и добавляет новую запись. Звучит как бюрократия, но иначе откат либо падает, либо тихо ничего не делает.
Я проверил весь путь на маленьком изменении: одна буква в клиентском скрипте. Скомпилировали локально, результат совпал с серверным. Установили, выгрузили обратно. Описание виджета, скомпилированный код и запись истории побайтно совпали с собранным пакетом. В дизайнере статус «Опубликовано», номер версии вырос на один, черновика нет.
Цепочка сред и реестр переноса
Теперь о том, куда всё это едет. У нас несколько сред с разными ролями.
Есть техническая песочница под нашим полным контролем. Там проверяем инструменты и ставим эксперименты. Есть общий сервер разработки, где работают бизнес-задачи. С него изменения уходят на тест, а потом в продакшн. На тест и в продакшн изменения переносит компания-партнёр по своему процессу. Продакшн для наших агентов только на чтение. Агент может посмотреть, что видит пользователь. Изменить ничего не может, даже «для проверки».
Перед общим сервером есть ещё приёмка. Кандидат ставится на изолированный стенд, где проверяется весь затронутый бизнес-маршрут настоящими кнопками формы. Не «HTTP вернул 200», а реальные задачи, записи и итоговые статусы. Успешный ответ на публикацию доказывает только то, что запрос дошёл. Что изменение работает, доказывает только проверка после публикации.
И здесь появилась ещё одна простая вещь, которой мне не хватало годами. Реестр изменений, которые готовы у нас и ждут переноса. На каждое изменение — карточка: что меняется, как проверить на тесте, как проверить на продакшне только чтением. Статусы три: «Ожидает переноса», «На Test», «На PROD». Ещё есть «Отменено».
Звучит как обычный трекер. Но без него очень легко потерять доработку, которая готова две недели и всё это время просто лежит. Реестр делает видимым разрыв между «у нас готово» и «у пользователей работает».
CI для собственного инструмента
Вокруг этого конвейера у нас вырос внутренний веб-сервис команды, Wiki. Его мы тоже разрабатываем с агентами. Поэтому к нему требования строже, чем к скриптам виджетов.
На каждый коммит и запрос на слияние запускаются тесты. Дальше собираются редактор и каталог интерфейсных компонентов. Затем запускается пачка браузерных проверок: настоящий браузер открывает страницы и проходит сценарии. Скриншоты и отчёты этих проверок хранятся неделю, чтобы можно было посмотреть, что именно видел тест.
Обновление продакшна устроено с той же паранойей. Команда запускается только с чистой основной ветки. На сервер уходит архив ровно той ревизии, которая закоммичена. Никаких «у меня локально ещё пара правок».
Сборка и тесты идут прямо на сервере, а старая версия сервиса в это время продолжает работать. Остановят её только после того, как новая собралась и прошла тесты. Перед переключением снимается резервная копия, а прежний контейнер сохраняется для отката. После переключения проверяются здоровье сервиса, HTTPS и то, что API без входа не отвечает. Если новая версия не поднялась, автоматически возвращается старая.
И одна деталь про доверие. Команда подключается только к серверу с заранее известным ключом. Если ключ неизвестен или изменился, она отказывается работать. Пароли и ключи не попадают ни в репозиторий, ни в аргументы команд, ни в архив релиза.
Что я вынес из этой истории
Первое. Low-code — это разработка. Как только у вас больше одного разработчика или хотя бы один ИИ-агент, вам нужны файлы, дифф, ревью и повторяемая установка. Иначе вы не знаете, что работает у пользователей.
Второе. «Документированного пути нет» не всегда значит «пути нет». Иногда это значит, что надо посмотреть, что на самом деле делает кнопка. У нас за кнопкой оказался обычный компилятор TypeScript, и его удалось повторить честно, без обходов защиты.
Третье. Самые опасные ошибки — тихие. Импорт, который успешно ничего не сделал. Исходник, который не совпадает с исполняемым кодом. Обновление, которое удаляет доработку без единого предупреждения. Поэтому каждый шаг у нас заканчивается не «команда завершилась успешно», а сравнением фактического состояния с ожидаемым.
И напоследок о той самой кнопке, которая оставалась активной при ошибках компиляции. Мы теперь не верим ни кнопкам, ни кодам выхода. Верим только повторной выгрузке и диффу.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.