1- # Build the sovereign `lem` binary for every target and ship the zips.
2- # One binary named `lem` per platform (the GPU backend — metal/rocm/cuda — loads
3- # its kernel sidecar at runtime); targets are separated by build folder, not by
4- # binary name. Zips flow to a rolling `dev` prerelease.
1+ # Build the sovereign `lem` binary for every hosted-buildable cell and ship the zips.
2+ #
3+ # lem links DuckDB (go-store/chathistory) via cgo, so there is NO pure-Go build and
4+ # NO cheap cross-compile: each cell is a NATIVE cgo build on a matching runner.
5+ # GitHub owns the portable cpu cells it can build natively; the ROCm/CUDA cells
6+ # (amd, cuda) stay on GitLab's homelab runner (it has the toolchain); the metal
7+ # cell needs a macOS 26 / Metal 4 box to compile the metallibs, so it rides a
8+ # self-hosted Mac lane (dormant until ENABLE_MACOS_METAL is set).
9+ #
10+ # Every build ships in two packagings of the same binary (see docs/release-artifacts.md):
11+ # Native {os}-{arch}-lem-{backend}-{version}.zip -> binary `lem`
12+ # Driver {os}-{arch}-lem-driver-{backend}-{version}.zip -> binary `lem-{backend}`
13+ # Zips flow to a rolling `dev` prerelease; version is `dev-<short-sha>`.
514name : Build
615
716on :
817 push :
918 branches : [dev, main]
19+ tags : ['v*'] # a version tag publishes the REAL release (guarded on-main)
1020 workflow_dispatch :
1121
1222permissions :
1323 contents : read
1424
25+ env :
26+ GO_VERSION : " 1.26"
27+
1528jobs :
16- build :
17- name : lem · ${{ matrix.goos }}/${{ matrix.goarch }}
18- runs-on : ${{ matrix.os }}
29+ # ---- cpu cells: native cgo, one per hosted os/arch (duckdb ships prebuilt libs
30+ # for darwin/{amd64,arm64}, linux/{amd64,arm64}, windows/amd64 — NOT
31+ # windows/arm64, which is therefore not a cell). The workspace (go.work)
32+ # wires cli -> local go/; GOWORK=off cannot build the binary because cli
33+ # pins a published go/inference tag that lags the local tree. ----------------
34+ cpu :
35+ name : lem cpu · ${{ matrix.os_name }}/${{ matrix.arch }}
36+ runs-on : ${{ matrix.runner }}
1937 strategy :
2038 fail-fast : false
2139 matrix :
2240 include :
23- - { os: macos-latest, goos: darwin, goarch: arm64, metal: true }
24- - { os: ubuntu-latest, goos: linux, goarch: amd64 }
25- - { os: ubuntu-24.04-arm, goos: linux, goarch: arm64 }
26- - { os: windows-latest, goos: windows, goarch: amd64 }
27- env :
28- CGO_ENABLED : " 1"
29- GOWORK : " off"
41+ # Dropped cells, proven by live runs 2026-07-22 (docs/release-artifacts.md):
42+ # - macos/x86_64: the macos-13 runner label is retired (queued forever) —
43+ # no hosted Intel Mac remains; revisit only on real demand.
44+ # - windows/x86_64: lem's code is not Windows-portable yet (unix-only
45+ # syscall.Kill + raw-fd reads outside build constraints) — a code
46+ # campaign, not a CI matrix entry; restore the cell when it compiles.
47+ - { runner: macos-latest, os_name: macos, arch: aarch64, exe: "" }
48+ - { runner: ubuntu-latest, os_name: linux, arch: x86_64, exe: "" }
49+ - { runner: ubuntu-24.04-arm, os_name: linux, arch: aarch64, exe: "" }
3050 steps :
3151 - uses : actions/checkout@v4
32- with :
33- submodules : recursive # metal needs external/mlx
52+ # cpu build needs neither external/mlx (metal) nor the ROCm sources.
3453
3554 - uses : actions/setup-go@v5
3655 with :
37- go-version : " 1.26"
38-
39- - name : Install Task
40- run : go install github.com/go-task/task/v3/cmd/task@latest
56+ go-version : ${{ env.GO_VERSION }}
57+ # go.sum lives in the modules, not the repo root; list both so setup-go's
58+ # cache key is honest (build touches cli, cli requires local go/).
59+ cache-dependency-path : |
60+ go/go.sum
61+ cli/go.sum
4162
42- - name : Build lem
63+ - name : Build lem (native cgo; workspace wires cli -> local go/)
4364 shell : bash
4465 env :
45- METAL : ${{ matrix.metal }}
66+ CGO_ENABLED : " 1"
67+ EXE : ${{ matrix.exe }}
4668 run : |
47- set -e
48- if [ "$METAL" = "true" ]; then
49- task metallib && task build:embed # self-contained metal lem
50- else
51- task build:native # plain lem (GPU sidecar loads at runtime)
52- fi
69+ set -euo pipefail
70+ if [[ "$GITHUB_REF" == refs/tags/v* ]]; then VERSION="$GITHUB_REF_NAME"; else VERSION="dev-${GITHUB_SHA:0:12}"; fi
71+ mkdir -p bin
72+ go build -trimpath -ldflags "-X main.version=${VERSION}" -o "bin/lem${EXE}" ./cli
73+ test -s "bin/lem${EXE}"
74+
75+ - uses : actions/upload-artifact@v4
76+ with :
77+ name : lem-cpu-${{ matrix.os_name }}-${{ matrix.arch }}
78+ path : bin/lem${{ matrix.exe }}
79+ if-no-files-found : error
80+
81+ # ---- metal cell: macos/aarch64, self-contained (metallibs embedded). Hosted
82+ # macOS runners are < macOS 26 and cannot compile the Metal 4 metallibs, so
83+ # a plain build would ship a metallib-less, non-runnable binary. This lane is
84+ # DORMANT on normal pushes (the `if` is false when the variable is unset, so
85+ # it never queues or fails): register a self-hosted macOS 26 arm64 runner and
86+ # set repo variable ENABLE_MACOS_METAL=true to activate it. Interim: the metal
87+ # artifact is built manually on the owner's Mac (task metallib && task build:embed).
88+ metal :
89+ name : lem metal · macos/aarch64 (self-hosted macOS 26)
90+ if : ${{ vars.ENABLE_MACOS_METAL == 'true' }}
91+ runs-on : [self-hosted, macOS, ARM64]
92+ steps :
93+ - uses : actions/checkout@v4
94+ with :
95+ submodules : recursive # external/mlx for `task metallib`
96+
97+ - uses : actions/setup-go@v5
98+ with :
99+ go-version : ${{ env.GO_VERSION }}
100+ cache-dependency-path : |
101+ go/go.sum
102+ cli/go.sum
103+
104+ - name : Install Task
105+ run : go install github.com/go-task/task/v3/cmd/task@latest
53106
54- - name : Assemble build folder (build/dist/<target>/ lem)
107+ - name : Build self-contained metal lem (metallibs baked in )
55108 shell : bash
56- env :
57- TARGET : ${{ matrix.goos }}-${{ matrix.goarch }}
58109 run : |
59- set -e
60- mkdir -p "build/dist/$TARGET"
61- cp bin/lem* "build/dist/$TARGET/"
110+ set -euo pipefail
111+ if [[ "$GITHUB_REF" == refs/tags/v* ]]; then VERSION="$GITHUB_REF_NAME"; else VERSION="dev-${GITHUB_SHA:0:12}"; fi
112+ task metallib
113+ task build:embed VERSION="${VERSION}"
114+ test -s bin/lem
62115
63116 - uses : actions/upload-artifact@v4
64117 with :
65- name : lem-${{ matrix.goos }}-${{ matrix.goarch }}
66- path : build/dist/${{ matrix.goos }}-${{ matrix.goarch }}/
118+ name : lem-metal-macos-aarch64
119+ path : bin/lem
120+ if-no-files-found : error
67121
122+ # ---- release: package each built binary under BOTH names (build once, package
123+ # twice). Two publish shapes: a dev push feeds the rolling `dev`
124+ # prerelease; a v* TAG (guarded: the tagged commit must be on main)
125+ # publishes the real versioned release. Requires cpu green; tolerates
126+ # metal skipped. ----
68127 release :
69- name : Zip + rolling dev prerelease
70- needs : build
71- if : github.event_name == 'push' && github.ref == 'refs/heads/dev'
128+ name : Zip (Native + Driver) + release
129+ needs : [cpu, metal]
130+ if : ${{ !cancelled() && needs.cpu.result == 'success' && github.event_name == 'push' && ( github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')) }}
72131 runs-on : ubuntu-latest
73132 permissions :
74133 contents : write
75134 steps :
135+ - uses : actions/checkout@v4
136+ with :
137+ fetch-depth : 0 # the tag-on-main ancestry guard needs history
138+
139+ # A tag release only publishes if the tagged commit is reachable from
140+ # main — "a tag hitting main". A tag pushed off-branch fails LOUDLY
141+ # (the run goes red) rather than silently minting a release.
142+ - name : Guard — tag must be on main
143+ if : startsWith(github.ref, 'refs/tags/v')
144+ shell : bash
145+ run : |
146+ set -euo pipefail
147+ git fetch -q origin main
148+ if git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
149+ echo "tag $GITHUB_REF_NAME is on main — releasing"
150+ else
151+ echo "::error::tag $GITHUB_REF_NAME ($GITHUB_SHA) is NOT on main — refusing to release"
152+ exit 1
153+ fi
154+
155+ - name : Resolve version + release shape
156+ id : shape
157+ shell : bash
158+ run : |
159+ set -euo pipefail
160+ if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
161+ echo "version=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
162+ echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
163+ echo "name=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
164+ echo "prerelease=false" >> "$GITHUB_OUTPUT"
165+ else
166+ echo "version=dev-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
167+ echo "tag=dev" >> "$GITHUB_OUTPUT"
168+ echo "name=dev (rolling)" >> "$GITHUB_OUTPUT"
169+ echo "prerelease=true" >> "$GITHUB_OUTPUT"
170+ fi
171+
76172 - uses : actions/download-artifact@v4
77173 with :
78- path : dist # one subfolder per target ( lem-<goos >-<goarch> /lem)
174+ path : dist # one dir per cell: dist/ lem-<backend >-<os>-<arch> /lem[.exe]
79175
80- - name : Zip each target
176+ - name : Package Native + Driver zips
177+ shell : bash
178+ env :
179+ VERSION : ${{ steps.shape.outputs.version }}
81180 run : |
82- set -e
181+ set -euo pipefail
182+ mkdir -p out
83183 cd dist
84- for d in */; do (cd "$d" && zip -r "../${d%/}.zip" .); done
85- ls -la *.zip
184+ # ONLY the binary artifacts (lem-cpu-*, lem-metal-*) — lem-sdks also
185+ # lands in dist/ and must never enter the packaging loop.
186+ for d in lem-cpu-*/ lem-metal-*/; do
187+ [ -d "$d" ] || continue # unmatched glob (metal skipped)
188+ name="${d%/}" # e.g. lem-cpu-macos-aarch64
189+ IFS=- read -r _ backend os arch <<< "$name" # backend/os/arch from the name
190+ bin="$(ls "$d")" # lem or lem.exe
191+ ext=""; case "$bin" in *.exe) ext=".exe";; esac
192+ # Native — the binary a user runs locally, named `lem`.
193+ cp "$d/$bin" "lem${ext}"; chmod 0755 "lem${ext}"
194+ zip -jq "../out/${os}-${arch}-lem-${backend}-${VERSION}.zip" "lem${ext}"
195+ rm -f "lem${ext}"
196+ # Driver — the binary a compute box serves over the API, named `lem-<backend>`.
197+ cp "$d/$bin" "lem-${backend}${ext}"; chmod 0755 "lem-${backend}${ext}"
198+ zip -jq "../out/${os}-${arch}-lem-driver-${backend}-${VERSION}.zip" "lem-${backend}${ext}"
199+ rm -f "lem-${backend}${ext}"
200+ done
201+ ls -la ../out
86202
87- - name : Publish rolling dev prerelease
203+ - name : Publish release
88204 uses : softprops/action-gh-release@v2
89205 with :
90- tag_name : dev
91- name : dev (rolling)
92- prerelease : true
93- files : dist /*.zip
206+ tag_name : ${{ steps.shape.outputs.tag }}
207+ name : ${{ steps.shape.outputs.name }}
208+ prerelease : ${{ steps.shape.outputs.prerelease }}
209+ files : out /*.zip
94210 env :
95211 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
96212
@@ -101,7 +217,10 @@ jobs:
101217 - uses : actions/checkout@v4
102218 - uses : actions/setup-go@v5
103219 with :
104- go-version : " 1.26"
220+ go-version : ${{ env.GO_VERSION }}
221+ cache-dependency-path : |
222+ go/go.sum
223+ cli/go.sum
105224 - uses : actions/setup-node@v4
106225 with :
107226 node-version : " 20"
@@ -111,7 +230,7 @@ jobs:
111230 java-version : " 21"
112231 - name : Install generators
113232 run : |
114- npm install -g @openapitools/openapi-generator-cli@7.22.0
233+ npm install -g @openapitools/openapi-generator-cli@2.40.0 # npm wrapper is 2.x; it manages the 7.x Java generator
115234 go install github.com/go-task/task/v3/cmd/task@latest
116235 - name : Generate SDKs (lem spec -> OpenAPI 3.1 -> typed clients)
117236 run : task sdk
0 commit comments