# ADR-ci-003: Cross-platform release builds (the D1 matrix)

## Status

**Accepted (2026-06-13); implemented the same day on the `ci` branch.** Every
fork was settled with the user. Verified end-to-end:

- all four targets cross-build locally from Linux x86_64;
- the Linux binaries are statically linked (D2); the Windows artifacts are
  valid PE32+ (x86-64 / Aarch64);
- a real release-matrix run (tag `v.0.0.0-citest3`) published **8 assets** — the
  four binaries + a `.sha256` each.

**Runtime-verified (2026-06-13, by the user):** the **Linux x86_64** and
**Windows aarch64** binaries launch and run correctly — one of each OS family
and both architectures. The remaining two (**Linux aarch64**, **Windows
x86_64**) are link-clean and valid format but not yet runtime smoke-tested.

This ADR records the **cross-platform build strategy**; it sits on top of
**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and
**ADR-ci-001** (the pipeline, whose release job this fills in).

## Amendment 2 — 2026-06-16: CI on `main`; `release-macos` dispatched + verified

The CI branch is **merged to `main`**, so `release-macos.yaml`
(`workflow_dispatch`, Gitea-default-branch-only) is now triggerable. It
has been **dispatched and verified end-to-end**: both `*-apple-darwin`
targets build on the Tart runner, the de-nix/re-sign step runs, the
assets upload to the tagged release, and the binaries launch. macOS is
therefore runtime-verified (alongside the original Linux x86_64 +
Windows aarch64); only **Linux aarch64** and **Windows x86_64** remain
link-clean / valid-format without a runtime smoke-test.

## Amendment — 2026-06-14: macOS implemented (closes D1)

macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
**Tart (Apple-Silicon) macOS runner** registered to Gitea — building on **real
Apple hardware** makes the SDK fully licensed, so the whole osxcross / SDK
grey-area + public-image-redistribution problem (§5 below) simply **does not
arise**. With all six D1 targets producing artifacts, **D1 is complete.**

Details, all verified on the runner via a throwaway smoke-test before wiring the
release leg:

- **`release-macos.yaml`** — `workflow_dispatch` with a `tag` input,
  `runs-on: macos`. The runner registered as `macos:host`, but `:host` is
  act_runner's execution-backend schema (run on host, no container), **not** part
  of the label, so the label is `macos`. Steps: `cargo test` (macOS gets the only
  automated test coverage outside the Linux gate — user choice) → build both
  darwin targets natively through the flake (`apple-sdk` added to the devShell so
  the toolchain links AppKit) → **upload to the same release** via the idempotent
  create-or-get.
- **De-nix + re-sign.** The darwin stdenv bakes a `/nix/store` `libiconv` load
  path into the binary (the *only* non-system dependency; everything else is
  AppKit/Foundation/CoreGraphics/IOKit + `libSystem`/`libobjc`). The release step
  rewrites it to `/usr/lib/libiconv.2.dylib` with `install_name_tool` and
  **re-signs ad-hoc** (`codesign -f -s -`) — `install_name_tool` invalidates the
  signature and Apple Silicon refuses an unsigned binary. A guard fails the build
  if any `/nix/store` path remains. Result: portable, signed binaries (the native
  one was confirmed to launch).
- **Dispatch-only, intermittent runner.** The Mac isn't always on, so macOS is a
  separate dispatched workflow (not a job in `release.yaml`) — a release always
  carries the four Linux/Windows assets regardless of the Mac, and the two macOS
  assets are added by dispatching `release-macos` for that tag. **Caveat:** Gitea
  exposes `workflow_dispatch` only for workflows on the **default branch**, so
  `release-macos` becomes triggerable once the CI work is merged to `main`.
- **Cache hygiene (host-execution runner).** The runner wipes the workspace each
  run, so cargo `target/` never accumulates; the persistent cache is the nix
  store, bounded by **generation** — record the current devShell in a persistent
  profile, keep the 2 newest generations (`nix-env --delete-generations +2`),
  reclaim the rest. (The first sweep reclaimed a ~3.8 GB one-time backlog of
  build scaffolding — source + build-only deps, not re-installed toolchains.)
- **D2 on macOS.** macOS binaries cannot be fully static (`libSystem` is always
  dynamic); "no runtime deps" there means *system libraries only*, which the
  de-nix step guarantees.

## Context

`requirements.md` **D1** asks for binaries on **Linux, macOS, Windows × x86_64
and aarch64** (six targets); **D2** asks for a **single static binary, no
runtime deps**. The CI runner executes jobs in a **Linux x86_64** container
(ADR-ci-001), so every target is **cross-compiled from Linux**.

What's feasible is decided almost entirely by one dependency — **`arboard`**
(the clipboard backend for the `copy` command). Its per-platform backends in
`Cargo.lock`:

| Target family | arboard backend | Needs a platform SDK to cross-link? |
|---|---|---|
| Linux x86_64 / aarch64 | `x11rb` (pure Rust) | No |
| Windows x86_64 / aarch64 | `clipboard-win` + `windows-sys` (import libs bundled) | No |
| **macOS x86_64 / aarch64** | **`objc2-app-kit` → links AppKit** | **Yes — Apple's SDK** |

So **four targets cross-compile with no SDK**; **macOS is the hard wall** —
AppKit can only be linked against Apple's SDK.

## Decision

### 1. Tooling — `cargo-zigbuild`

Cross-compile with **`cargo-zigbuild`** (Zig's bundled clang + libc as a single
universal cross `cc`/linker), added to the flake devShell alongside `zig`. One
tool serves every non-macOS target, **including the `cc`-crate compile of
rusqlite's bundled SQLite C**, with no per-target toolchain. It replaced the
earlier single-target musl `cc` (ADR-ci-002's first cut).

### 2. Targets this iteration — the four non-macOS

Added to `rust-toolchain.toml` and the release matrix:

- **`x86_64-unknown-linux-musl`**, **`aarch64-unknown-linux-musl`** — musl +
  `crt-static`, so **fully static** portable binaries (D2);
- **`x86_64-pc-windows-gnu`**, **`aarch64-pc-windows-gnullvm`** — Zig statically
  links its libc, so the `.exe` is **standalone** (no mingw runtime DLLs).

### 3. The Windows `synchronization` stub

Rust's `std` links **`-lsynchronization`** (its `WaitOnAddress`-based thread
parking). That import library is normally supplied by Rust's `rust-mingw`
"self-contained" component — which **rust-overlay does not ship** — and Zig's
mingw doesn't carry it either, so the link fails with *"unable to find dynamic
system library 'synchronization'"*. The functions (`WaitOnAddress`,
`WakeByAddress*`) are **forwarded by `kernel32`** (already linked), so an
**empty stub** `libsynchronization.a` (committed at **`ci/winstub/`**, 8 bytes,
wired via **`.cargo/config.toml`** for the Windows targets *only*) satisfies the
linker without contributing symbols. Host and Linux builds are untouched by it.

### 4. Workflow shape — test once, then a build matrix

`release.yaml` is **`test` → `build`**:

- **`test`** runs once on the host (`cargo test`) — a tag never publishes
  untested code;
- **`build`** is a **matrix over the four targets** (`needs: test`,
  `fail-fast: false`), each `cargo zigbuild --release --target <triple>`, then
  packages the binary (`.exe` for Windows) + a `.sha256` and uploads both to the
  **shared release** via an **idempotent create-or-get** (the first matrix job
  creates the release; the rest fetch it).

### 5. macOS — deferred, with rationale

macOS is **not** in this iteration. `arboard`→AppKit needs the macOS SDK, and:

- the SDK ships **only inside Xcode**; Apple's license ties its use to
  **Apple-branded hardware**, so using it on a Linux runner is a **grey area**
  (widely done, low enforcement, but technically against the terms);
- **redistributing** the SDK is a clearer violation — and our **CI image is
  public**, so the SDK **cannot be baked into it** even if the grey area were
  accepted; it would have to live in a private store;
- the **clean** path is building on **real Apple hardware** (a Mac registered as
  a Gitea runner, or hosted Mac CI), where the SDK is fully licensed.

macOS therefore becomes its **own step**, choosing between **(a)** osxcross + a
**private** SDK kept out of the public image, or **(b)** a **Mac runner**. The
user decides when we get there.

## Consequences

- **D1: four of six targets met** from a single Linux runner; **D2 met on
  Linux** (static musl). Windows `.exe`s are standalone.
- **Runtime coverage:** Linux x86_64 + Windows aarch64 confirmed running
  (user, 2026-06-13); Linux aarch64 + Windows x86_64 are the outstanding
  runtime checks.
- **Each matrix target recompiles from scratch** (~2–4 min; ~10 min total on the
  single runner), and Zig's per-target libc cache is cold each run. Fine at
  release frequency; cacheable later if it matters.
- **The empty stub depends on `kernel32` forwarding `WaitOnAddress`** (true on
  Windows 8+), which covers every supported target.
- **Asset naming** `rdbms-playground-<tag>-<target>[.exe]` is close to what
  `cargo-binstall` / the D3 package managers will want.

## Alternatives considered

- **`cross` (cross-rs).** Docker-image-per-target; covers Linux + Windows but
  **not macOS** (no legally redistributable Apple images), and needs DinD
  orchestration inside our job. Rejected — no macOS, more moving parts than
  zigbuild.
- **Per-target nix cross (`pkgsCross`).** Clean for Linux-musl and
  Windows-x86_64 (mingw-w64, which *does* ship `libsynchronization.a`), but
  Windows-aarch64 isn't readily packaged and **macOS-from-Linux is unsupported**
  in nixpkgs. Rejected — incomplete.
- **Native runners per OS.** Cleanest for macOS/Windows, but needs mac/windows
  runners we don't have. Kept on the table specifically for the deferred macOS
  step.
- **A real `libsynchronization.a`** (from nixpkgs mingw or a `rust-mingw`
  component) instead of the empty stub. More principled, but more flake
  machinery, doesn't cover Windows-aarch64, and unnecessary — the stub links
  clean because the symbols resolve via `kernel32`.

## Deferred / out of scope

- ~~**macOS** (x86_64 + aarch64)~~ — **done** via the Tart runner (see the
  2026-06-14 amendment); §5 below is the as-deferred rationale, kept for history.
- **D3 packaging** — Homebrew / Scoop / winget / `cargo-binstall` manifests
  (and binstall-friendly archive naming).
- **CI speed** — caching per-target builds / Zig's libc cache.
- **Runtime smoke test** of the two not-yet-checked targets (Linux aarch64,
  Windows x86_64).

## Relationship to other decisions

- **Extends ADR-ci-002** — the flake devShell now carries `cargo-zigbuild` +
  `zig` and the four release targets.
- **Fills in ADR-ci-001 §3 (Release)** — that single-target job is now this
  matrix.
- **Advances `requirements.md`** **D1** (4/6) and **D2** (Linux, done).
