DEV Community

Hagicode
Hagicode

Posted on

Building Multi-Platform code-server and OmniRoute with GitHub Actions

Building Multi-Platform code-server and OmniRoute with GitHub Actions

Facing the need to build and publish across Linux, macOS, and Windows platforms with unified releases, we designed a GitHub Actions-based multi-platform CI/CD pipeline. It's not that difficult when you think about it, but the roadblocks can definitely make you pull your hair out. This article shares the design philosophy and implementation details of this pipeline—including, of course, the pits we stepped in along the way.

Background

code-server is an open-source project that runs VS Code in a browser, allowing developers to work through a web IDE on a remote server. As HagiCode Desktop integrates code-server as its built-in runtime, we need to build, verify, and distribute customized versions of code-server across different operating systems (Linux, macOS, Windows).

This should have been pretty straightforward, but... when is life ever that easy?

Meanwhile, OmniRoute as a multi-model routing service also needs to share the same build and release pipeline with code-server. Although the two packages are built differently, they ultimately need to converge into the same GitHub Release. Like two originally non-intersecting lines, they still meet at some point in the end—call it fate, I suppose.

This brings several engineering challenges:

  1. Cross-platform build differences: The build toolchains for Linux, macOS, and Windows are completely different (Linux uses quilt + bash, macOS uses Homebrew, Windows requires MSYS2)—each platform has its own temperament
  2. Build artifact verification: After building, artifacts need to be automatically verified to start properly—after all, nobody wants to release something that doesn't run
  3. Unified version management: Two packages need to share the same version number and release tag—like two people sharing one name, there needs to be a system
  4. Parallel builds with serial publishing: Builds can run in parallel, but publishing needs coordination—this is where mistakes happen, and when they do, they're really mistakes

About HagiCode

The solution shared in this article comes from practical experience in the HagiCode project. HagiCode is an AI code assistant project that integrates code-server as a built-in runtime in its desktop product, thus requiring engineering solutions for multi-platform building and publishing. This is, to put it bluntly, just about getting the product out—nothing more.

Limitations of Upstream Build Pipeline

The code-server upstream project's own CI/CD pipeline (build.yaml) only builds for the linux-x64 platform, and its release process (publish.yaml) only targets npm, AUR, and Docker channels. It doesn't support:

  • Native builds for macOS and Windows—perhaps they don't think these platforms are important enough
  • Multi-platform matrix parallel builds—maybe the upstream team is small
  • Unified artifact verification mechanism—just publish it and let users try it themselves

That's fine, every project has its own priorities. We just happen to need these features, so we'll build them ourselves.

Design Decisions

Based on the above analysis, HagiCode designed an independent build pipeline in repos/vendered with the following core decisions:

1. Reuse shared version management and release toolchain

Version numbers use UTC date format YYYY.MMDD.RRRR, where RRRR is a zero-padded sequence of the GitHub Actions run number. This ensures monotonic incrementing and traceability of versions—after all, time doesn't flow backward, just like some things once changed cannot be undone:

// 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}`
}
Enter fullscreen mode Exit fullscreen mode

For example, the first build on 2026-05-05 generates version 2026.0505.0001 and tag v2026.0505.0001.

Actually, there's nothing special about this version format—it just happens to be good enough.

2. Package-isolated build scripts

Each package (code-server, omniroute) maintains its own build and verification logic under packages/<name>/scripts/, while shared publishing tools (scripts/versioning.mjs, scripts/github-release.mjs, scripts/publication.mjs) remain package-agnostic. Each manages its own affairs without interfering—this is what's called "staying in one's lane."

3. Unified metadata contract

All packages produce standardized metadata.json containing schemaVersion, packageId, version, platform, arch, sourceRevision, and artifacts[] fields, ensuring downstream consumers don't need to be aware of package differences. With a unified format, everyone can save some trouble.

Solution

Overall Workflow Architecture

The entire pipeline is defined in repos/vendered/.github/workflows/code-server-artifacts.yaml and includes the following stages:

prepare_release → build (matrix) → verify (matrix) → publish_github_release
Enter fullscreen mode Exit fullscreen mode

The process is simple if you look at it simply, complex if you look at it complexly—it all depends on your perspective.

Trigger Conditions

on:
  workflow_dispatch:          # Manual trigger
  schedule:
    - cron: "23 3 * * *"     # Daily scheduled build
  push:
    branches: [main]          # Trigger on push to main branch
    paths:                    # Only trigger on related file changes
      - ".github/workflows/code-server-artifacts.yaml"
      - ".gitmodules"
      - "scripts/**"
      - "packages/code-server/**"
      - "packages/omniroute/**"
Enter fullscreen mode Exit fullscreen mode

The daily scheduled build is set for 3:23 AM—no particular reason, just picked a random time. Perhaps the person who chose this time didn't think too much about it either.

Stage 1: Version Preparation

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"
Enter fullscreen mode Exit fullscreen mode

This stage generates a unified version number and Git tag, shared by all subsequent build and release steps. A good start saves a lot of trouble for the work ahead.

Stage 2: Multi-Platform Matrix Build

The build stage uses strategy.matrix to execute in parallel across different platforms:

code-server Build Matrix

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
Enter fullscreen mode Exit fullscreen mode

Key design: fail-fast: false ensures that a failure on one platform doesn't cancel builds on other platforms. After all, one platform failing doesn't mean all platforms have issues—no need for everyone to go down together.

omniroute Build Matrix

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
Enter fullscreen mode Exit fullscreen mode

OmniRoute's matrix is richer, including both Intel and ARM architectures for macOS. Note that macOS ARM uses the macos-14 runner (Apple Silicon), while Intel uses macos-15-intel. That's just how the world is—some things are always divided into camps—like Intel and ARM, never to reconcile.

Stage 3: Platform-Specific Prerequisites

Each platform requires different toolchains, and the workflow handles this through conditional steps:

Linux

- name: Install Linux prerequisites
  if: runner.os == 'Linux'
  run: sudo apt-get update && sudo apt-get install -y jq rsync quilt libkrb5-dev
Enter fullscreen mode Exit fullscreen mode

macOS

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

Windows (MSYS2)

Windows is the most complex, requiring MSYS2 to provide a Unix-like toolchain—there's no way around it, since Windows' design philosophy is completely different from Unix systems:

- 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)
Enter fullscreen mode Exit fullscreen mode

Actually, these configurations aren't that complex, but the first time you encounter them, they can be pretty confusing.

Stage 4: Build Artifact Verification

After building on each platform, verification steps download the artifacts, extract them, and actually start them to verify usability. After all, we don't want to release something that doesn't run—that would be too embarrassing:

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
Enter fullscreen mode Exit fullscreen mode

The verification script (verify-startup.mjs) will:

  1. Extract the build artifacts
  2. Start code-server on a random available port
  3. Poll the /healthz endpoint waiting for service readiness
  4. After confirming the service responds with 200, shut down the process
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`)
}
Enter fullscreen mode Exit fullscreen mode

Waiting for health checks always makes people a bit anxious—like waiting for someone who will never reply. Except this time the service will eventually start, while some people may never respond.

Stage 5: Unified Publishing

After all builds and verifications complete, the publishing stage collects the artifacts and creates a 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
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Concurrency control: Using concurrency ensures that publishes for the same tag don't execute in parallel—avoiding duplicate releases is generally a good thing
  • Conditional publishing: Only publish on push to main branch or manual trigger; scheduled builds only execute build and verification
  • Artifact aggregation: Use the pattern parameter of download-artifact to batch download all platform artifacts for both code-server and omniroute

Practice

Key Points for Cross-Platform Build Script Writing

Build scripts (build-artifacts.mjs) need to handle platform differences. Here are the key points:

1. Platform detection and normalization

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Different systems refer to the same platform differently—like the same person having different names in different contexts, but still being the same person.

2. Shell compatibility on Windows

On Windows, npm run calls cmd.exe, but code-server's build scripts depend on bash. The solution is to set the NPM_CONFIG_SCRIPT_SHELL environment variable and use MSYS2. There's no way around this, since Windows and Unix have completely different design philosophies:

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Artifact packaging

Different platforms use different archive formats (Linux/macOS use .tar.gz, Windows uses .zip)—each platform has its own preferences, just like everyone has their own habits:

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)])
}
Enter fullscreen mode Exit fullscreen mode

4. Patch management

code-server customization is implemented through quilt patches in the patches/ directory. Linux uses quilt directly, macOS installs quilt through Homebrew, and Windows needs to use quilt from MSYS2 or fall back to the patch command (this part is quite troublesome):

// Use patch command on Windows instead of 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 })
  }
}
Enter fullscreen mode Exit fullscreen mode

The Windows part definitely took a lot of time—no way around it, since Windows' design philosophy is different from other systems.

Version Number Design Considerations

HagiCode uses the YYYY.MMDD.RRRR format instead of upstream semantic versioning for the following reasons:

  • Determinism: Each build's version number is uniquely determined by date and run number
  • Monotonic incrementing: Date prefix ensures natural sorting is chronological order
  • Source traceability: Build time and CI run sequence number can be inferred from the version number

Actually, there's nothing special about this—it just happens to be good enough. Semantic versioning sounds nice, but it's actually quite troublesome to use in practice.

Important Notes

  1. Submodule recursive checkout: Must use submodules: recursive when building, ensuring complete cloning of upstream code for both code-server and omniroute (this place is easy to forget)
  2. Node version matching: code-server build uses the Node version specified in upstream .node-version file; omniroute uses Node 24
  3. Windows home directory: OmniRoute on Windows CI needs to manually create $HOME directory structure to avoid build scripts accessing non-existent paths—Windows directory structure is different from other systems
  4. Verification timeout: code-server startup verification has a 60-second timeout and needs adjustment based on actual startup speed
  5. Artifact slimming: Delete embedded Node binary after building (slimRelease), since downstream will use its own Node runtime
  6. Publish idempotency: github-release.mjs supports updating existing Releases (delete old Asset first then upload new one), ensuring retry safety

These are all lessons learned from stepping in pits—of course, when you're in the pit, it really makes you want to pull your hair out.

Complete CI/CD Flow Diagram

┌─────────────────────────────────────────────────────────────────┐
│                     Trigger Sources                              │
│  push to main / workflow_dispatch / cron(23 3 * * *)            │
└──────────────────────────┬──────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  prepare_release                                                 │
│  Generate version: 2026.0506.0001, tag: 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      │
│ startup+healthz│ │ startup+healthz│ │ startup+healthz│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
       │                │                │
       └────────────────┼────────────────┘
                        │
       ┌────────────────┼────────────────┐
       ▼                ▼                ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ omniroute    │ │ omniroute    │ │ omniroute    │ ...
│ linux-amd64  │ │ macos-amd64  │ │ macos-arm64  │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
       │                │                │
       └────────────────┼────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────────────────┐
│  publish_github_release                                          │
│  Download all artifacts → Create/update GitHub Release → Upload │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

This flowchart looks quite complex, but when you break it down, it's not that difficult. Many things are like this—they look scary, but when you actually do them, they're just whatever.

Key Configuration Reference

# Build environment variables
env:
  CI: true
  GITHUB_TOKEN: ${{ github.token }}
  ELECTRON_SKIP_BINARY_DOWNLOAD: 1    # Skip Electron download
  PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1  # Skip Playwright browser download
  npm_config_build_from_source: true   # Build native modules from source
  VERSION: ${{ needs.prepare_release.outputs.version }}
Enter fullscreen mode Exit fullscreen mode

These environment variables are crucial for build speed and correctness: skipping unnecessary binary downloads significantly reduces build time, and build_from_source ensures native modules compile correctly on the target platform.

Through this pipeline, HagiCode achieves automated building, verification, and publishing of code-server and OmniRoute across three operating systems, turning what was originally a manual multi-platform publishing process into a fully automated CI/CD process. This can be considered making a troublesome thing less troublesome.

Summary

The key to designing multi-platform CI/CD pipelines lies in:

  • Centralized version management: Generate a unified version number at the start of the pipeline, shared by all downstream steps
  • Separation of build and publish: Use fail-fast: false to ensure a platform failure doesn't affect other platforms, with the publishing stage aggregating all artifacts
  • Platform-isolated build scripts: Each package maintains its own build logic, while shared toolchain remains package-agnostic
  • Automated artifact verification: Verify usability immediately after building to avoid discovering problems only after publishing

This solution is not only applicable to code-server and OmniRoute, but can also provide reference for other projects needing multi-platform builds. The build system shared in this article is exactly what we actually stepped on pits and optimized during the development of HagiCode. If you find this solution valuable, it shows our engineering strength is not bad—then HagiCode itself is worth paying attention to.

After all, people who can automate such troublesome things probably aren't too bad themselves.

References


If this article helps you:

Top comments (0)