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:
- 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
- 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
- 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
- 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}`
}
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
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/**"
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"
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
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
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
macOS
- name: Install macOS prerequisites
if: runner.os == 'macOS'
run: brew install jq rsync quilt python-setuptools
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)
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
The verification script (verify-startup.mjs) will:
- Extract the build artifacts
- Start code-server on a random available port
- Poll the
/healthzendpoint waiting for service readiness - 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`)
}
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
Key points:
-
Concurrency control: Using
concurrencyensures 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
mainbranch or manual trigger; scheduled builds only execute build and verification -
Artifact aggregation: Use the
patternparameter ofdownload-artifactto 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"
}
}
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,
}
}
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)])
}
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 })
}
}
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
-
Submodule recursive checkout: Must use
submodules: recursivewhen building, ensuring complete cloning of upstream code for both code-server and omniroute (this place is easy to forget) -
Node version matching: code-server build uses the Node version specified in upstream
.node-versionfile; omniroute uses Node 24 -
Windows home directory: OmniRoute on Windows CI needs to manually create
$HOMEdirectory structure to avoid build scripts accessing non-existent paths—Windows directory structure is different from other systems - Verification timeout: code-server startup verification has a 60-second timeout and needs adjustment based on actual startup speed
-
Artifact slimming: Delete embedded Node binary after building (
slimRelease), since downstream will use its own Node runtime -
Publish idempotency:
github-release.mjssupports 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 │
└─────────────────────────────────────────────────────────────────┘
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 }}
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: falseto 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
- HagiCode Project Repository
- HagiCode Official Website
- code-server Upstream Repository
- OmniRoute Project
- GitHub Actions Documentation
If this article helps you:
- Come to GitHub and give us a Star: github.com/HagiCode-org/site
- Visit the official website to learn more: hagicode.com
- Watch the official release demo video: www.bilibili.com/video/BV1z4oWB3EpY/
- One-click install to try: docs.hagicode.com/installation/docker-compose
- Desktop quick install: hagicode.com/desktop/
- Public beta has started, welcome to install and try
Top comments (0)