ci: ship a runnable x86_64-linux archive with each release #361

Merged
natsukium merged 3 commits from release/issue-360-linux-archive into main 2026-09-23 04:46:42 +09:00
Owner

felis-x86_64-linux.tar.gz: the package's closure, relocated so a Linux host
without Nix unpacks it and runs bin/felis. It replaces the
felis-x86_64-linux.txt identity file, which only helped someone who already
ran Nix. The macOS half of #360 follows in its own pull request; the darwin
.txt and its place in the asset loop are untouched here.

What changed

  • nix/dist.nix + nix/dist-linux.sh build packages.felis-dist. The
    launchers exec a bundled ld.so with --library-path and --argv0, so the
    bundled libraries win at any prefix without patchelf; the loader sits in
    bin/ so the sibling lookup that finds felis-daemon still lands on a
    launcher. XLOCALEDIR is the one export, and only as a default: the client
    snapshots its environment into every session it creates, so LOCPATH or
    GCONV_PATH would reach the user's shells. Host library directories come
    after lib/, because the GPU stack is host state.
  • Every closure path is classified as bundled or host-supplied; one in neither
    list fails the build. The tar is byte-reproducible, so a local
    nix build .#felis-dist yields the bytes the release attaches.
  • crates/felis-daemon/src/relay.rs: the relay starts the sibling
    felis-daemon rather than re-execing current_exe, which under the archive
    is the loader. Unit-tested both ways; every layout that ships today is
    unchanged, since there the sibling is the file current_exe names.
  • release.yml gains linux-package, which unpacks the archive, checks the
    manifest and the NEEDED closure, runs it in an ubuntu:24.04 container with
    no /nix, and re-runs verify.py --identity against the archive's own
    bin/felis. release attaches the tarball. pr.yml builds .#felis-dist
    so a broken bundler fails before a tag.
  • An AppImage was rejected although the issue asked for one: a type-2 AppImage
    is a FUSE mount that unmounts when the runtime's direct child exits, and the
    daemon outlives the window that spawned it. The rationale is in the commit
    body and the triage plan on #360.

Verified

  • nix build .#felis-dist on x86_64-linux: 21 MB archive, 59 MB unpacked.
  • The manifest, launcher/share grep and readelf NEEDED checks by hand on the
    extracted tree, with the script the job runs.
  • The container smoke by hand, with the same script and mounts the job uses:
    the CLI runs, the daemon it spawns is the archive's own
    (/proc/<pid>/exe is bin/.ld.so), PTY children inherit no LOCPATH,
    GCONV_PATH, LD_LIBRARY_PATH or XKB_CONFIG_ROOT, the relay starts a cold
    daemon that outlives it, the bundled glibc resolves en_US.UTF-8 from the
    host's archive, ja_JP.UTF-8 compose tables resolve off Nix, and the client
    paints a frame through Ubuntu's own lavapipe.
  • just fmt-check lint prose-check docs-links, cargo nextest run -p felis-daemon
    (590 passed), shellcheck on the three new scripts, actionlint on both
    workflows (no new findings).

Refs #360

`felis-x86_64-linux.tar.gz`: the package's closure, relocated so a Linux host without Nix unpacks it and runs `bin/felis`. It replaces the `felis-x86_64-linux.txt` identity file, which only helped someone who already ran Nix. The macOS half of #360 follows in its own pull request; the darwin `.txt` and its place in the asset loop are untouched here. ## What changed - `nix/dist.nix` + `nix/dist-linux.sh` build `packages.felis-dist`. The launchers exec a bundled `ld.so` with `--library-path` and `--argv0`, so the bundled libraries win at any prefix without patchelf; the loader sits in `bin/` so the sibling lookup that finds `felis-daemon` still lands on a launcher. `XLOCALEDIR` is the one export, and only as a default: the client snapshots its environment into every session it creates, so `LOCPATH` or `GCONV_PATH` would reach the user's shells. Host library directories come after `lib/`, because the GPU stack is host state. - Every closure path is classified as bundled or host-supplied; one in neither list fails the build. The tar is byte-reproducible, so a local `nix build .#felis-dist` yields the bytes the release attaches. - `crates/felis-daemon/src/relay.rs`: the relay starts the sibling `felis-daemon` rather than re-execing `current_exe`, which under the archive is the loader. Unit-tested both ways; every layout that ships today is unchanged, since there the sibling is the file `current_exe` names. - `release.yml` gains `linux-package`, which unpacks the archive, checks the manifest and the NEEDED closure, runs it in an `ubuntu:24.04` container with no `/nix`, and re-runs `verify.py --identity` against the archive's own `bin/felis`. `release` attaches the tarball. `pr.yml` builds `.#felis-dist` so a broken bundler fails before a tag. - An AppImage was rejected although the issue asked for one: a type-2 AppImage is a FUSE mount that unmounts when the runtime's direct child exits, and the daemon outlives the window that spawned it. The rationale is in the commit body and the triage plan on #360. ## Verified - `nix build .#felis-dist` on x86_64-linux: 21 MB archive, 59 MB unpacked. - The manifest, launcher/share grep and `readelf` NEEDED checks by hand on the extracted tree, with the script the job runs. - The container smoke by hand, with the same script and mounts the job uses: the CLI runs, the daemon it spawns is the archive's own (`/proc/<pid>/exe` is `bin/.ld.so`), PTY children inherit no `LOCPATH`, `GCONV_PATH`, `LD_LIBRARY_PATH` or `XKB_CONFIG_ROOT`, the relay starts a cold daemon that outlives it, the bundled glibc resolves `en_US.UTF-8` from the host's archive, `ja_JP.UTF-8` compose tables resolve off Nix, and the client paints a frame through Ubuntu's own lavapipe. - `just fmt-check lint prose-check docs-links`, `cargo nextest run -p felis-daemon` (590 passed), `shellcheck` on the three new scripts, `actionlint` on both workflows (no new findings). Refs #360
The relay re-execs itself as `serve` to bring up a cold daemon on a
remote host. That holds only while the relay binary is the daemon. A
distribution that runs the binaries through a bundled dynamic loader
makes `current_exe` the loader, and the re-exec would run the loader
with `serve` as its argument.

Resolving the sibling `felis-daemon` first is the rule the client's
autospawn and the systemd hand-off already use. In every layout that
ships today the sibling is the file `current_exe` names, so nothing
there changes.

Assisted-by: Claude Code
ci: ship a runnable x86_64-linux archive with each release
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m8s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m5s
pr / nix flake check (pull_request) Successful in 47s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m2s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 32s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
windows / cargo nextest (Windows) (pull_request) Successful in 4m22s
windows / frontend smoke (Windows) (pull_request) Successful in 1m50s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
177e6a53e4
A Linux user without Nix had nothing to download: the release page
offered a store path and a narHash, which are only useful to someone who
already runs Nix. `felis-x86_64-linux.tar.gz` replaces that identity
file with the thing itself, matching what Windows already gets.

The archive is the package's closure, relocated: `bin/` launchers exec a
bundled `ld.so` with `--library-path`, which makes the bundled libraries
win at any prefix without patchelf, and `--argv0` keeps the usage line
and the process name honest. The loader lives in `bin/` so the sibling
lookup that finds `felis-daemon` still lands on a launcher. Only
`XLOCALEDIR` is exported, and only as a default: the client snapshots its
environment into every session it creates, so `LOCPATH` or `GCONV_PATH`
would reach the user's shells and point a host glibc at bundled data.
Host directories come after `lib/` because the GPU stack is host state.

An AppImage was rejected although the issue asked for one. A type-2
AppImage is a FUSE mount owned by the runtime process and unmounts when
its direct child exits, while the daemon is designed to outlive the
window that spawned it; it would take SIGBUS on its next page-in. The
workaround is an AppRun that copies the payload out first, which is a
self-extracting installer with an AppImage extension.

Every closure path is classified as bundled or host-supplied, and a path
in neither list fails the build, so a new runtime dependency is a red
build rather than a library that silently ships or silently goes
missing. The tar is byte-reproducible, so a local `nix build
.#felis-dist` yields what the release attaches.

Proving it needs a host that is not this one: `linux-package` unpacks the
archive and runs it in an `ubuntu:24.04` container with no `/nix`, where
the daemon it spawns must be the archive's own, a PTY child's
environment must carry no loader or locale override, the bundled glibc
must resolve a locale from the host's archive, compose tables must
resolve off Nix, and the client must paint a frame through the host's
Vulkan stack.

Refs #360

Assisted-by: Claude Code
ci: export felis-dist only where it builds, and state the host's side of the archive
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m4s
darwin / detect relevant changes (pull_request) Successful in 4s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 8s
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 51s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m26s
pr / nix flake check (pull_request) Successful in 26s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m52s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
windows / cargo nextest (Windows) (pull_request) Successful in 3m47s
windows / frontend smoke (Windows) (pull_request) Successful in 1m38s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
e5fc9dd77f
`packages.felis-dist` stood for every system and refused to evaluate on
darwin, which made `nix flake show --all-systems` and any flake check on
a darwin host fail on an attribute that has no business existing there
yet. Exporting it on Linux alone leaves the assembler with one host to
reason about and a clean place for the macOS bundle to land.

The install section claimed the archive needs no distribution packages
and then asked for a Vulkan driver, `libvulkan.so.1` and fonts. Both are
true of different things: it needs no *Nix*, and it needs the host's GPU
and font stack. Naming the glibc bound with them is what lets a reader
predict the one failure this design can have, since the host's driver
libraries load into a process already running the bundled glibc.

Assisted-by: Claude Code
natsukium deleted branch release/issue-360-linux-archive 2026-09-23 04:46:42 +09:00
Commenting is not possible because the repository is archived.
No description provided.