콘텐츠로 이동

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 세 플랫폼에서 빌드하고 통합 배포해야 하는 요구사항에 직면하여, 우리는 GitHub Actions 기반의 다중 플랫폼 CI/CD 파이프라인을 설계했습니다. 사실 이게 어렵다고도 하기 뭐한데, 삽질할 때는 정말 머리 빠지는 줄 알았습니다. 이 글에서는 이 파이프라인의 설계思路과 구현 상세를 공유합니다. 물론 우리가 겪은 삽질들도요.

배경

code-server는 VS Code를 브라우저에서 실행하는 오픈소스 프로젝트로, 개발자가 원격 서버의 Web IDE를 통해 개발할 수 있게 합니다. HagiCode 데스크톱 버전이 code-server를 내장 런타임으로 통합하면서, 우리는 다양한 운영체제(Linux, macOS, Windows)에서 code-server의 커스텀 버전을 빌드, 검증, 배포해야 했습니다.

이건 원래 간단해야 했는데… 인생이 그렇게 쉽나요?

한편 OmniRoute는 다중 모델 라우팅 서비스로, code-server와 동일한 빌드 및 배포 파이프라인을 공유해야 합니다. 두 패키지는 빌드 방식이 다르지만 최종적으로는 동일한 GitHub Release에 모여서 배포되어야 합니다. 마치 원래 교차하지 않던 두 선이 어느 지점에서 만나는 것처럼 — 이게所谓的 운명이겠죠.

이로 인해 몇 가지 엔지니어링 도전이 발생했습니다:

  1. 크로스 플랫폼 빌드 차이: Linux, macOS, Windows 세 플랫폼의 빌드 툴체인이 완전히 다릅니다(Linux는 quilt + bash, macOS는 Homebrew, Windows는 MSYS2 사용) — 각 플랫폼마다 제 멋대로죠
  2. 빌드 아티팩트 검증: 빌드 완료 후 아티팩트가 정상적으로 시작되는지 자동으로 검증해야 합니다 — 그려려니 아예 안 돌아가는 걸 배포하고 싶진 않잖아요
  3. 통합 버전 관리: 두 패키지가 동일한 버전 번호와 릴리스 태그를 공유해야 합니다 — 마치 두 사람이 한 이름을 공유하는 거죠, 뭐라도 규칙은 있어야겠죠
  4. 병렬 빌드와 직렬 배포: 빌드는 병렬로 가능하지만 배포는 조율이 필요합니다 — 여기서 실수하기 쉽고, 한번 잘못하면 진짜 잘못된 겁니다

HagiCode에 대해

이 글에서 공유하는 솔루션은 HagiCode 프로젝트의 실천 경험에서 나왔습니다. HagiCode는 AI 코드 어시스턴트 프로젝트로, 데스크톱 제품에 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 실행 번호의 0으로 채워진 시퀀스입니다. 이는 버전의 단조 증가와 추적 가능성을 보장합니다 — 시간은 거꾸로 흐르지 않으니까요, 어떤 일은 일단 일어나면 바꿀 수 없는 것처럼:

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[] 필드를 포함하여 다운스트림 소비자가 패키지 차이를 인식할 필요가 없도록 합니다. 통합된 포맷이 있으면 모두가 좀 편해지죠.

해결책

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] # 메인 브랜치 푸시 시 트리거
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은 macos-14 runner(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를 사용하여 동일한 태그의 배포가 병렬로 실행되지 않도록 합니다 — 중복 배포를 피하는 건 좋은 일입니다
  • 조건부 배포: main 브랜치 푸시 또는 수동 트리거 시에만 배포하며, 정기 빌드는 빌드와 검증만 실행
  • 아티팩트 집계: download-artifact의 pattern 매개변수를 사용하여 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에서의 Shell 호환성

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의 커스터마이징은 patches/ 디렉터리의 quilt 패치로 구현됩니다. Linux는 quilt를 직접 사용하고, macOS는 Homebrew로 quilt를 설치하며, Windows는 MSYS2의 quilt를 사용하거나 patch 명령으로 대체해야 합니다(이 부분이 좀 귀찮습니다):

// Windows에서 patch 명령으로 quilt 대체
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 형식을 채택했고, 그 이유는 다음과 같습니다:

  • 결정론성: 각 빌드의 버전 번호는 날짜와 실행 번호로 고유하게 결정됩니다
  • 단조 증가: 날짜 접두사는 자연 정렬이 시간 순서임을 보장합니다
  • 소스 추적 가능: 버전 번호에서 빌드 시간과 CI 실행 순서를 추론할 수 있습니다

사실 이것도 별거 아닙니다, 그냥 쓸만하면 됐습니다. 시맨틱 버전 같은 건 말은 좋지만 실제로 쓰려면 꽤 귀찮습니다.

주의사항

  1. 서브모듈 재귀 체크아웃: 빌드 시 submodules: recursive를 사용해야 하며, code-server와 omniroute의 업스트림 코드가 완전히 pulled되도록 합니다(이 부분을 잊기 쉽습니다)
  2. Node 버전 매칭: code-server 빌드는 업스트림 .node-version 파일에 지정된 Node 버전을 사용하고, omniroute는 Node 24를 사용합니다
  3. Windows 홈 디렉터리: OmniRoute는 Windows CI에서 $HOME 디렉터리 구조를 수동으로 생성해야 하며, 빌드 스크립트가 존재하지 않는 경로에 접근하는 것을 방지합니다 — Windows의 디렉터리 구조는 다른 시스템과 다릅니다
  4. 검증 타임아웃: code-server 시작 검증은 60초 타임아웃으로 설정되어 있으며, 실제 시작 속도에 따라 조정이 필요합니다
  5. 아티팩트 다이어트: 빌드 완료 후 내장된 Node 바이너리(slimRelease)를 삭제하며, 다운스트림은 자체 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 파이프라인 설계의 핵심은 다음과 같습니다:

  • 버전 번호 중앙 관리: 파이프라인 시작 시 통합 버전 번호를 생성하고 모든 다운스트림 단계에서 공유
  • 빌드와 배포 분리: fail-fast: false를 사용하여 특정 플랫폼 실패가 다른 플랫폼에 영향을 주지 않도록 하고, 배포 단계에서 모든 아티팩트를 집계
  • 플랫폼 격리 빌드 스크립트: 각 패키지는 자체 빌드 로직을 유지하고 공유 툴체인은 패키지 무관성 유지
  • 아티팩트 자동화 검증: 빌드 후 즉시 사용 가능성을 검증하여 배포 후에야 문제를 발견하는 것을 방지

이 솔루션은 code-server와 OmniRoute뿐만 아니라 다중 플랫폼 빌드가 필요한 다른 프로젝트에도 참고가 될 수 있습니다. 이 글에서 공유한 빌드 시스템은 HagiCode 개발 과정에서 실제로 삽질하고 실제로 최적화한 솔루션입니다. 이 솔루션이 가치가 있다고 느끼신다면, 우리의 엔지니어링 실력이 나쁘지 않다는 뜻입니다 — 그렇다면 HagiCode 자체도值得关注할 만합니다.

어쨌든, 이런 귀찮은 일을 자동화할 수 있는 사람은 그렇게까지 나쁘지는 않겠죠.

참고 자료


이 글이 도움이 되셨다면:

开始使用 HagiCode

一次安装,几分钟上手

HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。