Как использовать GitHub Actions для сборки мультиплатформенного code-server и OmniRoute
Как использовать GitHub Actions для сборки мультиплатформенного code-server и OmniRoute
Перед лицом необходимости сборки и унифицированной публикации на трёх платформах — Linux, macOS и Windows, мы разработали конвейер CI/CD для мультиплатформенной сборки на основе GitHub Actions. На самом деле это не так уж и сложно, только когда попадаешь в ямы, это действительно сводит с ума. В этой статье мы поделимся концепцией проектирования и деталями реализации этого конвейера — и, конечно, теми ямами, в которые мы наступали.
Предыстория
code-server — это проект с открытым исходным кодом, который запускает VS Code в браузере, позволяя разработчикам заниматься разработкой через веб-IDE на удалённом сервере. По мере того как desktop-версия HagiCode использует code-server как встроенную среду выполнения, нам необходимо собирать, проверять и распространять кастомизированную версию code-server на различных операционных системах (Linux, macOS, Windows).
Это должно было быть довольно простым делом, но… жизнь ведь не такая простая?
В то же время, OmniRoute как сервис маршрутизации множества моделей также должен использовать тот же конвейер сборки и публикации, что и code-server. Хотя два пакета собираются по-разному, в итоге им нужно быть собранными в одном GitHub Release. Как две линии, которые изначально не пересекались, но в конечном итоге встречаются в какой-то точке — это так называемая судьба.
Это привело к нескольким инженерным вызовам:
- Различия в кроссплатформенной сборке: цепочки инструментов сборки на трёх платформах — Linux, macOS и Windows — полностью различаются (Linux использует quilt + bash, macOS использует Homebrew, Windows требует MSYS2) — у каждой платформы свой характер
- Проверка артефактов сборки: после завершения сборки нужно автоматически проверить, могут ли артефакты нормально запуститься — в конце концов, никто не хочет публиковать то, что вообще не запустится
- Унифицированное управление версиями: двум пакетам нужно использовать один и тот же номер версии и тег публикации — как если бы двум людям нужно было использовать одно имя, это должно быть как-то обосновано
- Параллельная сборка и последовательная публикация: сборка может быть параллельной, но публикация требует скоординированности — здесь легко ошибиться, а если ошибёшься, то это действительно ошибка
О HagiCode
Решение, представленное в этой статье, основано на практическом опыте проекта HagiCode. HagiCode — это проект ИИ-помощника по коду, в его desktop-продукте интегрирован code-server как встроенная среда выполнения, поэтому необходимо решить инженерные задачи мультиплатформенной сборки и публикации. Это, говоря прямо, просто для того, чтобы сделать продукт, и всё.
Ограничения исходного конвейера сборки
Включённый в проект code-server исходный конвейер CI/CD (build.yaml) собирает только для платформы linux-x64, а его процесс публикации (publish.yaml) ориентирован только на каналы npm, AUR и Docker. Он не поддерживает:
- Нативную сборку для macOS и Windows — возможно, считается, что эти две платформы недостаточно важны
- Параллельную сборку матрицы мультиплатформенности — возможно, в исходной команде мало людей
- Унифицированный механизм проверки артефактов — в любом случае, публикуем и даём пользователям самим пробовать
Это ничего, у каждого проекта свои приоритеты. Просто нам как раз нужны эти функции, поэтому сделаем сами.
Решения по проектированию
На основе вышеуказанного анализа в HagiCode в repos/vendered разработан независимый конвейер сборки, ключевые решения следующие:
1. Повторное использование унифицированных инструментов управления версиями и публикации
Номер версии использует формат даты UTC YYYY.MMDD.RRRR, где RRRR — это заполненная нулями последовательность номеров запуска GitHub Actions. Это обеспечивает монотонное увеличение и отслеживаемость версии —毕竟 время не идёт вспять, как некоторые вещи, однажды случившись, не могут быть изменены:
export function formatDateVersion({ date = new Date(), revision }) { const year = normalizedDate.getUTCFullYear() const month = String(normalizedDate.getUTCMonth() + 1).padStart(2, "0") const day = String(normalizedDate.getUTCDate()).padStart(2, "0") return `${year}.${month}${day}.${normalizedRevision}`}Например, первая сборка от 2026-05-05 сгенерирует версию 2026.0505.0001 и тег v2026.0505.0001.
На самом деле этот формат номера версии ничего особенного, просто как раз достаточно.
2. Изолированные скрипты сборки на уровне пакетов
Каждый пакет (code-server, omniroute) поддерживает собственную логику сборки и проверки в packages/<name>/scripts/, общие инструменты публикации (scripts/versioning.mjs, scripts/github-release.mjs, scripts/publication.mjs) сохраняют независимость от пакетов. Каждый занимается своим делом, не мешая друг другу — это примерно так называемое «вода в колодце не нарушает воду в реке».
3. Унифицированный контракт метаданных
Все пакеты выдают стандартизированный metadata.json, содержащий поля schemaVersion, packageId, version, platform, arch, sourceRevision и artifacts[], обеспечивая, чтобы downstream-потребителям не нужно было воспринимать различия пакетов. С унифицированным форматом все могут сэкономить силы.
Решение
Общая архитектура Workflow
Весь конвейер определён в repos/vendered/.github/workflows/code-server-artifacts.yaml и включает следующие этапы:
prepare_release → build (matrix) → verify (matrix) → publish_github_releaseПроцесс, если сказать просто — простой, если сказать сложно — тоже сложный —关键是 как посмотреть.
Условия запуска
on: workflow_dispatch: # Ручной запуск schedule: - cron: "23 3 * * *" # Ежедневная сборка по расписанию push: branches: [main] # Запуск при push в главную ветку paths: # Запуск только при изменении соответствующих файлов - ".github/workflows/code-server-artifacts.yaml" - ".gitmodules" - "scripts/**" - "packages/code-server/**" - "packages/omniroute/**"Ежедневная запланированная сборка установлена на 3:23 утра — нет никакой особой причины, просто выбрали произвольное время. Возможно, тот, кто выбрал это время, особо не задумывался.
Этап 1: Подготовка версии
jobs: prepare_release: runs-on: ubuntu-22.04 outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 22 - id: version run: node ./scripts/versioning.mjs >> "$GITHUB_OUTPUT"На этом этапе генерируются унифицированный номер версии и Git-тег, все последующие этапы сборки и публикации делят эти два значения. Хорошее начало, по крайней мере, это экономит много проблем для последующей работы.
Этап 2: Сборка мультиплатформенной матрицы
Этап сборки использует strategy.matrix для параллельного выполнения на разных платформах:
Матрица сборки code-server
build_code_server: needs: prepare_release strategy: fail-fast: false matrix: include: - name: code-server Linux runner: ubuntu-22.04 artifact_name: code-server-linux - name: code-server macOS runner: macos-latest artifact_name: code-server-macos - name: code-server Windows runner: windows-latest artifact_name: code-server-windowsКлючевая конструкция: fail-fast: false гарантирует, что сбой на одной платформе не отменит сборку на других платформах. Ведь если одна платформа упала, это не значит, что у всех платформ есть проблемы, нет необходимости всем вместе идти на казнь.
Матрица сборки omniroute
build_omniroute: needs: prepare_release strategy: fail-fast: false matrix: include: - name: omniroute Linux x64 runner: ubuntu-22.04 platform: linux arch: amd64 - name: omniroute macOS x64 runner: macos-15-intel platform: macos arch: amd64 - name: omniroute macOS arm64 runner: macos-14 platform: macos arch: arm64 - name: omniroute Windows x64 runner: windows-latest platform: windows arch: amd64Матрица OmniRoute богаче, включает две архитектуры macOS — Intel и ARM. Обратите внимание, что macOS ARM использует runner macos-14 (Apple Silicon), Intel использует macos-15-intel. Мир такой, всегда есть что-то разделённое на лагери — как Intel и ARM, никогда не примирятся.
Этап 3: Платформенно-специфичные предварительные условия
Каждой платформе требуется разная цепочка инструментов, Workflow обрабатывает это через условные шаги:
Linux
- name: Install Linux prerequisites if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y jq rsync quilt libkrb5-devmacOS
- name: Install macOS prerequisites if: runner.os == 'macOS' run: brew install jq rsync quilt python-setuptoolsWindows (MSYS2)
Windows самый сложный, требует MSYS2 для предоставления Unix-подобной цепочки инструментов — это также неизбежно,毕竟 философия дизайна Windows полностью отличается от Unix-систем:
- name: Setup MSYS2 if: runner.os == 'Windows' uses: msys2/setup-msys2@v2 with: msystem: MSYS path-type: inherit update: true install: >- diffutils jq patch quilt rsync unzip zip
- name: Configure Windows shell paths if: runner.os == 'Windows' shell: pwsh run: | Add-Content -Path $env:GITHUB_ENV -Value 'NPM_CONFIG_SCRIPT_SHELL=/usr/bin/bash' Add-Content -Path $env:GITHUB_ENV -Value ("MSYS2_CMD={0}\\setup-msys2\\msys2.cmd" -f $env:RUNNER_TEMP)На самом деле эти конфигурации не так уж сложны, только при первой встрече это действительно может немного сбить с толку.
Этап 4: Проверка артефактов сборки
После завершения сборки на каждой платформе шаг проверки скачивает артефакты, распаковывает и фактически запускает для проверки доступности. Ведь мы не хотим публиковать то, что вообще не запустится — это слишком позорно:
verify_code_server: needs: build_code_server strategy: fail-fast: false matrix: include: - name: code-server Linux runner: ubuntu-22.04 bash_path: bash - name: code-server Windows runner: windows-latest bash_path: C:\msys64\usr\bin\bash.exeСкрипт проверки (verify-startup.mjs) выполнит:
- Распаковку артефактов сборки
- Запуск code-server на случайном доступном порту
- Опрос конечной точки
/healthzожидание готовности сервиса - После подтверждения ответа сервиса 200 закрытие процесса
async function waitForHealth(port) { const deadline = Date.now() + 60_000 while (Date.now() < deadline) { const response = await requestHealth(port) if (response.statusCode === 200) return await new Promise((resolve) => setTimeout(resolve, 1000)) } throw new Error(`Timed out waiting for code-server to become healthy`)}Ожидание проверки здоровья всегда вызывает немного тревоги — как ожидание человека, который никогда не ответит на сообщение. Только в этот раз сервис в конечном итоге запустится, а некоторые люди могут никогда не ответить вам.
Этап 5: Унифицированная публикация
После завершения всей сборки и проверки этап публикации собирает артефакты и создаёт GitHub Release:
publish_github_release: needs: - prepare_release - build_code_server - build_omniroute - verify_code_server - verify_omniroute if: >- ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }} concurrency: group: ${{ format('vendered-github-release-{0}', needs.prepare_release.outputs.tag) }} cancel-in-progress: falseКлючевые моменты:
- Контроль параллелизма: использование
concurrencyгарантирует, что публикация одного и того же тега не будет выполняться параллельно — избежать дублирования публикации в любом случае хорошо - Условная публикация: публикация только при push в ветку
mainили ручном запуске, запланированная сборка выполняет только сборку и проверку - Сбор артефактов: использование параметра
patterndownload-artifactдля массового скачивания всех артефактов всех платформ code-server и omniroute
Практика
Ключевые моменты написания кроссплатформенных скриптов сборки
Скрипт сборки (build-artifacts.mjs) должен обрабатывать различия платформ,以下是 ключевые моменты:
1. Определение платформы и нормализация
function normalizePlatform(value) { switch (String(value).toLowerCase()) { case "darwin": case "macos": return "macos" case "win32": case "windows": case "windows_nt": return "windows" default: return "linux" }}Разные системы называют одну и ту же платформу по-разному — как у одного человека в разных случаях могут быть разные имена, но в итоге это всё тот же человек.
2. Совместимость оболочки на Windows
На Windows npm run вызывает cmd.exe, но скрипты сборки code-server зависят от bash. Решение — установить переменную окружения NPM_CONFIG_SCRIPT_SHELL и использовать MSYS2. Это также неизбежно,毕竟 философия дизайна Windows и Unix совершенно различна:
function withCodeServerEnv(env) { const scriptShell = platform === "windows" ? "/usr/bin/bash" : env.BASH_PATH || "bash" return { ...env, NPM_CONFIG_SCRIPT_SHELL: platform === "windows" ? scriptShell : env.NPM_CONFIG_SCRIPT_SHELL, }}3. Упаковка артефактов
Разные платформы используют разные форматы архивов (Linux/macOS используют .tar.gz, Windows использует .zip) — у каждой платформы свои предпочтения, как у каждого человека свои жизненные привычки:
if (platform === "windows") { await run("powershell.exe", [ "-NoLogo", "-NoProfile", "-Command", `Compress-Archive -Path '${releaseDir}' -DestinationPath '${archivePath}' -Force`, ])} else { await run("tar", ["-czf", archivePath, "-C", codeServerRoot, path.basename(releaseDir)])}4. Управление патчами
Кастомизация code-server реализована через патчи quilt в каталоге patches/. Linux напрямую использует quilt, macOS устанавливает quilt через Homebrew, Windows нужно использовать quilt из MSYS2 или отступить к команде patch (это довольно хлопотно):
// Использование команды patch вместо quilt на Windowsasync function applyPatchesWithPatch(env) { const series = await readFile(path.join(codeServerRoot, "patches", "series"), "utf8") const patchFiles = series.split(/\r?\n/) .map(line => line.trim()) .filter(line => line && !line.startsWith("#"))
for (const patchFile of patchFiles) { await runMsys2(`patch -p1 --forward -i "patches/${patchFile}"`, { cwd: codeServerRoot, env }) }}С частью Windows действительно возились много времени — ничего не поделаешь, кто же знал, что философия дизайна Windows отличается от других систем.
Соображения по дизайну номера версии
HagiCode использует формат YYYY.MMDD.RRRR, а не семантическую версию upstream, по следующим причинам:
- Определённость: номер версии каждой сборки уникально определяется датой и номером запуска
- Монотонное увеличение: префикс даты гарантирует, что естественная сортировка соответствует хронологическому порядку
- Отслеживаемость источника: из номера версии можно вывести время сборки и порядковый номер запуска CI
На самом деле это ничего особенного, просто как раз достаточно. Семантическая версия,这个东西, звучит хорошо, только на практике довольно хлопотно.
Меры предосторожности
- Рекурсивный checkout подмодулей: при сборке необходимо использовать
submodules: recursive, чтобы гарантировать полное скачивание исходного кода upstream code-server и omniroute (это место легко забыть) - Соответствие версии Node: сборка code-server использует версию Node, указанную в файле upstream
.node-version, omniroute использует Node 24 - Домашний каталог Windows: OmniRoute на Windows CI требует вручную создать структуру каталога
$HOME, чтобы избежать доступа скриптов сборки к несуществующим путям — структура каталогов Windows отличается от других систем - Тайм-аут проверки: проверка запуска code-server устанавливает тайм-аут 60 секунд, нужно регулировать в зависимости от фактической скорости запуска
- Похудение артефактов: после сборки удалить встроенный бинарный Node (
slimRelease), потому что downstream будет использовать свою среду выполнения Node - Идемпотентность публикации:
github-release.mjsподдерживает обновление существующего Release (сначала удалить старый Asset, затем загрузить новый), гарантируя безопасность повтора
Эти вещи — опыт, полученный при наступании на ямы — конечно, когда наступаешь на ямы, это действительно сводит с ума.
Полная диаграмма потока CI/CD
┌─────────────────────────────────────────────────────────────────┐│ Источники запуска ││ push to main / workflow_dispatch / cron(23 3 * * *) │└──────────────────────────┬──────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────┐│ prepare_release ││ Генерация версии: 2026.0506.0001, тег: v2026.0506.0001 │└──────────────────────────┬──────────────────────────────────────┘ │ ┌────────────┼────────────┐ ▼ ▼ ▼┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ code-server │ │ code-server │ │ code-server ││ Linux │ │ macOS │ │ Windows ││ ubuntu-22.04 │ │ macos-latest │ │win-latest │└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ ▼ ▼ ▼┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ verify │ │ verify │ │ verify ││ Linux │ │ macOS │ │ Windows ││ запуск+healthz│ │ запуск+healthz│ │ запуск+healthz│└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ └────────────────┼────────────────┘ │ ┌────────────────┼────────────────┐ ▼ ▼ ▼┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ omniroute │ │ omniroute │ │ omniroute │ ...│ linux-amd64 │ │ macos-amd64 │ │ macos-arm64 │└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ └────────────────┼────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────┐│ publish_github_release ││ Скачать все артефакты → Создать/обновить GitHub Release → Загрузить архивы │└─────────────────────────────────────────────────────────────────┘Эта диаграмма потока выглядит довольно сложной, только если разбить, то на самом деле не так уж сложно. Многие дела таковы — выглядят страшно, а делать — ничего особенного.
Ключевая конфигурация для справки
# Переменные окружения сборкиenv: CI: true GITHUB_TOKEN: ${{ github.token }} ELECTRON_SKIP_BINARY_DOWNLOAD: 1 # Пропустить загрузку Electron PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 # Пропустить загрузку браузера Playwright npm_config_build_from_source: true # Собирать нативные модули из исходного кода VERSION: ${{ needs.prepare_release.outputs.version }}Эти переменные окружения критически важны для скорости и правильности сборки: пропуск ненужной загрузки бинарников может значительно сократить время сборки, build_from_source гарантирует правильную компиляцию нативных модулей на целевой платформе.
С помощью этого конвейера HagiCode реализовал автоматизированную сборку, проверку и публикацию code-server и OmniRoute на трёх операционных системах, превратив原本 требующий ручных операций процесс мультиплатформенной публикации в полностью автоматизированный процесс CI/CD. Это тоже можно считать превращением хлопотного дела в не такое хлопотное.
Итоги
Ключ к проектированию мультиплатформенного конвейера CI/CD заключается в следующем:
- Централизованное управление версиями: в начале конвейера генерируется унифицированный номер версии, всеми downstream-шагами делятся
- Разделение сборки и публикации: использование
fail-fast: falseгарантирует, что сбой на одной платформе не повлияет на другие платформы, этап публикации только собирает все артефакты - Изолированные скрипты сборки платформ: каждый пакет поддерживает собственную логику сборки, общая цепочка инструментов сохраняет независимость от пакетов
- Автоматическая проверка артефактов: немедленная проверка доступности после сборки, чтобы избежать обнаружения проблем только после публикации
Это решение применимо не только к code-server и OmniRoute, но и может служить справочником для других проектов, которым требуется мультиплатформенная сборка. Представленная в этой статье система сборки — это именно то решение, которое мы практически наступали на ямы и практически оптимизировали в процессе разработки HagiCode. Если вы считаете, что это решение ценное, это означает, что наши инженерные возможности неплохи — тогда сам HagiCode заслуживает внимания.
В конце концов, люди, которые могут автоматизировать такие хлопотные дела, наверное, тоже не слишком плохи.
Справочные материалы
- Адрес проекта HagiCode
- Официальный сайт HagiCode
- Репозиторий upstream code-server
- Проект OmniRoute
- Документация GitHub Actions
Если эта статья вам помогла:
- Приходите на GitHub дать звёздочку: github.com/HagiCode-org/site
- Посетите официальный сайт, чтобы узнать больше: hagicode.com
- Посмотрите видео презентации официальной версии: www.bilibili.com/video/BV1z4oWB3EpY/
- Установка в один клик для опыта: docs.hagicode.com/installation/docker-compose
- Быстрая установка desktop-версии: hagicode.com/desktop/
- Открытое бета-тестирование началось, добро пожаловать на установку и опыт
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。