Перейти к содержимому

Как использовать GitHub Actions для сборки мультиплатформенного code-server и OmniRoute

HagiCode for Windows Microsoft Store artwork
HagiCode for Windows is now on Microsoft Store
HagiCode for Windows is officially live on Microsoft Store. Windows users can install it directly from the storefront and stay on the store-managed update path. Open the listing and take a look.
Open Microsoft Store

Как использовать 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. Как две линии, которые изначально не пересекались, но в конечном итоге встречаются в какой-то точке — это так называемая судьба.

Это привело к нескольким инженерным вызовам:

  1. Различия в кроссплатформенной сборке: цепочки инструментов сборки на трёх платформах — Linux, macOS и Windows — полностью различаются (Linux использует quilt + bash, macOS использует Homebrew, Windows требует MSYS2) — у каждой платформы свой характер
  2. Проверка артефактов сборки: после завершения сборки нужно автоматически проверить, могут ли артефакты нормально запуститься — в конце концов, никто не хочет публиковать то, что вообще не запустится
  3. Унифицированное управление версиями: двум пакетам нужно использовать один и тот же номер версии и тег публикации — как если бы двум людям нужно было использовать одно имя, это должно быть как-то обосновано
  4. Параллельная сборка и последовательная публикация: сборка может быть параллельной, но публикация требует скоординированности — здесь легко ошибиться, а если ошибёшься, то это действительно ошибка

О 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. Это обеспечивает монотонное увеличение и отслеживаемость версии —毕竟 время не идёт вспять, как некоторые вещи, однажды случившись, не могут быть изменены:

scripts/versioning.mjs
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-dev

macOS

- name: Install macOS prerequisites
if: runner.os == 'macOS'
run: brew install jq rsync quilt python-setuptools

Windows (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) выполнит:

  1. Распаковку артефактов сборки
  2. Запуск code-server на случайном доступном порту
  3. Опрос конечной точки /healthz ожидание готовности сервиса
  4. После подтверждения ответа сервиса 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 или ручном запуске, запланированная сборка выполняет только сборку и проверку
  • Сбор артефактов: использование параметра pattern download-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 на Windows
async 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

На самом деле это ничего особенного, просто как раз достаточно. Семантическая версия,这个东西, звучит хорошо, только на практике довольно хлопотно.

Меры предосторожности

  1. Рекурсивный checkout подмодулей: при сборке необходимо использовать submodules: recursive, чтобы гарантировать полное скачивание исходного кода upstream code-server и omniroute (это место легко забыть)
  2. Соответствие версии Node: сборка code-server использует версию Node, указанную в файле upstream .node-version, omniroute использует Node 24
  3. Домашний каталог Windows: OmniRoute на Windows CI требует вручную создать структуру каталога $HOME, чтобы избежать доступа скриптов сборки к несуществующим путям — структура каталогов Windows отличается от других систем
  4. Тайм-аут проверки: проверка запуска code-server устанавливает тайм-аут 60 секунд, нужно регулировать в зависимости от фактической скорости запуска
  5. Похудение артефактов: после сборки удалить встроенный бинарный Node (slimRelease), потому что downstream будет использовать свою среду выполнения Node
  6. Идемпотентность публикации: 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 for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。