GitHub Actions로 다중 플랫폼 code-server와 OmniRoute 빌드하는 방법
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에 모여서 배포되어야 합니다. 마치 원래 교차하지 않던 두 선이 어느 지점에서 만나는 것처럼 — 이게所谓的 운명이겠죠.
이로 인해 몇 가지 엔지니어링 도전이 발생했습니다:
- 크로스 플랫폼 빌드 차이: Linux, macOS, Windows 세 플랫폼의 빌드 툴체인이 완전히 다릅니다(Linux는 quilt + bash, macOS는 Homebrew, Windows는 MSYS2 사용) — 각 플랫폼마다 제 멋대로죠
- 빌드 아티팩트 검증: 빌드 완료 후 아티팩트가 정상적으로 시작되는지 자동으로 검증해야 합니다 — 그려려니 아예 안 돌아가는 걸 배포하고 싶진 않잖아요
- 통합 버전 관리: 두 패키지가 동일한 버전 번호와 릴리스 태그를 공유해야 합니다 — 마치 두 사람이 한 이름을 공유하는 거죠, 뭐라도 규칙은 있어야겠죠
- 병렬 빌드와 직렬 배포: 빌드는 병렬로 가능하지만 배포는 조율이 필요합니다 — 여기서 실수하기 쉽고, 한번 잘못하면 진짜 잘못된 겁니다
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으로 채워진 시퀀스입니다. 이는 버전의 단조 증가와 추적 가능성을 보장합니다 — 시간은 거꾸로 흐르지 않으니까요, 어떤 일은 일단 일어나면 바꿀 수 없는 것처럼:
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: amd64OmniRoute의 매트릭스는 더 풍부하며, 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-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를 사용하여 동일한 태그의 배포가 병렬로 실행되지 않도록 합니다 — 중복 배포를 피하는 건 좋은 일입니다 - 조건부 배포:
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 실행 순서를 추론할 수 있습니다
사실 이것도 별거 아닙니다, 그냥 쓸만하면 됐습니다. 시맨틱 버전 같은 건 말은 좋지만 실제로 쓰려면 꽤 귀찮습니다.
주의사항
- 서브모듈 재귀 체크아웃: 빌드 시
submodules: recursive를 사용해야 하며, code-server와 omniroute의 업스트림 코드가 완전히 pulled되도록 합니다(이 부분을 잊기 쉽습니다) - Node 버전 매칭: code-server 빌드는 업스트림
.node-version파일에 지정된 Node 버전을 사용하고, omniroute는 Node 24를 사용합니다 - Windows 홈 디렉터리: OmniRoute는 Windows CI에서
$HOME디렉터리 구조를 수동으로 생성해야 하며, 빌드 스크립트가 존재하지 않는 경로에 접근하는 것을 방지합니다 — Windows의 디렉터리 구조는 다른 시스템과 다릅니다 - 검증 타임아웃: code-server 시작 검증은 60초 타임아웃으로 설정되어 있으며, 실제 시작 속도에 따라 조정이 필요합니다
- 아티팩트 다이어트: 빌드 완료 후 내장된 Node 바이너리(
slimRelease)를 삭제하며, 다운스트림은 자체 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 파이프라인 설계의 핵심은 다음과 같습니다:
- 버전 번호 중앙 관리: 파이프라인 시작 시 통합 버전 번호를 생성하고 모든 다운스트림 단계에서 공유
- 빌드와 배포 분리:
fail-fast: false를 사용하여 특정 플랫폼 실패가 다른 플랫폼에 영향을 주지 않도록 하고, 배포 단계에서 모든 아티팩트를 집계 - 플랫폼 격리 빌드 스크립트: 각 패키지는 자체 빌드 로직을 유지하고 공유 툴체인은 패키지 무관성 유지
- 아티팩트 자동화 검증: 빌드 후 즉시 사용 가능성을 검증하여 배포 후에야 문제를 발견하는 것을 방지
이 솔루션은 code-server와 OmniRoute뿐만 아니라 다중 플랫폼 빌드가 필요한 다른 프로젝트에도 참고가 될 수 있습니다. 이 글에서 공유한 빌드 시스템은 HagiCode 개발 과정에서 실제로 삽질하고 실제로 최적화한 솔루션입니다. 이 솔루션이 가치가 있다고 느끼신다면, 우리의 엔지니어링 실력이 나쁘지 않다는 뜻입니다 — 그렇다면 HagiCode 자체도值得关注할 만합니다.
어쨌든, 이런 귀찮은 일을 자동화할 수 있는 사람은 그렇게까지 나쁘지는 않겠죠.
참고 자료
이 글이 도움이 되셨다면:
- GitHub에서 Star 주세요: 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 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。