release: ship runnable Linux and macOS binaries instead of identity text files #360

Closed
opened 2026-09-23 01:44:21 +09:00 by natsukium · 3 comments
Owner

Parent: #31. Decided 2026-09-23 after the v0.1.0-rc.2 rehearsal.

Decision

The release ships runnable binaries for every supported target and drops the identity text files. felis-x86_64-linux.txt and felis-aarch64-darwin.txt pinned a store path in the binary cache; a user on a machine without Nix could not run anything from the release page for two of the three claimed targets. Binary assets replace them:

  • x86_64-linux: one relocated tarball (felis-x86_64-linux.tar.gz) carrying the package's binaries, the glibc and windowing libraries they need, terminfo, completions and man pages (felis, felis-client, felis-daemon, terminfo, completions), built from the same gate-verified derivation the cache receives. It must work on a distribution without Nix, and the client's daemon auto-spawn (current_exe().parent().join("felis-daemon")) must keep finding the daemon inside the tree.
  • aarch64-darwin: felis-aarch64-darwin.tar.gz with felis.app (client + daemon, from nix/make-macos-app.sh), the felis CLI beside it, the terminfo source, README and LICENSE. Every /nix/store dylib the Mach-O binaries link is copied into Contents/Frameworks and the install names rewritten, so the app runs on a Mac without Nix. Ad-hoc codesign; Developer ID signing and notarization are out of scope for v0.1.0 (first launch needs right-click → Open).
  • x86_64-pc-windows-msvc: the zip, unchanged.
  • felis-config.schema.json and felis.proto: unchanged.

The binary cache push stays as the Nix user's path; it is no longer what the release page points at for a non-Nix user.

Cascade

  • release.yml: the two identity steps stop producing .txt assets; verify.py --identity keeps checking the built binary's reported identity against the tag before anything is packaged. New packaging jobs (or steps in linux-publish / darwin-publish) upload the tarball and the tarball as run artifacts; the release job downloads and attaches them; the asset-presence check names the new set; release notes name the assets.
  • nix/: a derivation or bundler for the tarball; a dylib-bundling step for the app (may extend make-macos-app.sh behind a flag so nix build on a Nix Mac still produces the store-referencing app).
  • Docs: docs/reference/workspace.md asset table and release-gate paragraphs; docs/explanation/testing.md "Why a support claim needs a passed gate" (the durable-distribution clause now points at the binary assets); docs/how-to/install.md (a section per target for the release assets, keeping the flake path first); README.md "Status and compatibility"; the grep sweep for .txt, identity, store path across docs/.

Acceptance criteria

  • A tag run attaches the tarball, the darwin tarball, the Windows zip, the config schema and felis.proto, and no .txt.
  • The tarball runs felis --version, and felis-client opens a window and spawns its daemon, on an x86_64-linux host without Nix.
  • felis.app from the tarball launches on a Mac without Nix (otool -L shows no /nix/store path).
  • Every page that names the release assets names the same set.

Amended 2026-09-23: Linux ships a tarball rather than an AppImage. A type-2 AppImage's FUSE mount ends with the launcher's child, but the daemon outlives the window; and the pinned nixpkgs has no appimagetool. See the "Triage plan (2026-09-23)" comment for the reviewed design.

Parent: #31. Decided 2026-09-23 after the `v0.1.0-rc.2` rehearsal. ## Decision The release ships runnable binaries for every supported target and drops the identity text files. `felis-x86_64-linux.txt` and `felis-aarch64-darwin.txt` pinned a store path in the binary cache; a user on a machine without Nix could not run anything from the release page for two of the three claimed targets. Binary assets replace them: - `x86_64-linux`: one relocated tarball (`felis-x86_64-linux.tar.gz`) carrying the package's binaries, the glibc and windowing libraries they need, terminfo, completions and man pages (`felis`, `felis-client`, `felis-daemon`, terminfo, completions), built from the same gate-verified derivation the cache receives. It must work on a distribution without Nix, and the client's daemon auto-spawn (`current_exe().parent().join("felis-daemon")`) must keep finding the daemon inside the tree. - `aarch64-darwin`: `felis-aarch64-darwin.tar.gz` with `felis.app` (client + daemon, from `nix/make-macos-app.sh`), the `felis` CLI beside it, the terminfo source, README and LICENSE. Every `/nix/store` dylib the Mach-O binaries link is copied into `Contents/Frameworks` and the install names rewritten, so the app runs on a Mac without Nix. Ad-hoc `codesign`; Developer ID signing and notarization are out of scope for v0.1.0 (first launch needs right-click → Open). - `x86_64-pc-windows-msvc`: the zip, unchanged. - `felis-config.schema.json` and `felis.proto`: unchanged. The binary cache push stays as the Nix user's path; it is no longer what the release page points at for a non-Nix user. ## Cascade - `release.yml`: the two identity steps stop producing `.txt` assets; `verify.py --identity` keeps checking the built binary's reported identity against the tag before anything is packaged. New packaging jobs (or steps in `linux-publish` / `darwin-publish`) upload the tarball and the tarball as run artifacts; the `release` job downloads and attaches them; the asset-presence check names the new set; release notes name the assets. - `nix/`: a derivation or bundler for the tarball; a dylib-bundling step for the app (may extend `make-macos-app.sh` behind a flag so `nix build` on a Nix Mac still produces the store-referencing app). - Docs: `docs/reference/workspace.md` asset table and release-gate paragraphs; `docs/explanation/testing.md` "Why a support claim needs a passed gate" (the durable-distribution clause now points at the binary assets); `docs/how-to/install.md` (a section per target for the release assets, keeping the flake path first); `README.md` "Status and compatibility"; the grep sweep for `.txt`, `identity`, `store path` across docs/. ## Acceptance criteria - [ ] A tag run attaches the tarball, the darwin tarball, the Windows zip, the config schema and `felis.proto`, and no `.txt`. - [ ] The tarball runs `felis --version`, and `felis-client` opens a window and spawns its daemon, on an `x86_64-linux` host without Nix. - [ ] `felis.app` from the tarball launches on a Mac without Nix (`otool -L` shows no `/nix/store` path). - [ ] Every page that names the release assets names the same set. **Amended 2026-09-23:** Linux ships a tarball rather than an AppImage. A type-2 AppImage's FUSE mount ends with the launcher's child, but the daemon outlives the window; and the pinned nixpkgs has no appimagetool. See the "Triage plan (2026-09-23)" comment for the reviewed design.
Author
Owner

Triage plan (2026-09-23)

Reviewed design (eight adversarial rounds, PASS). Deviates from the body above in one point: Linux ships a relocated tarball, not an AppImage; the reasons are in the Decision section. The issue body is amended accordingly.

Goal

A tag run attaches, for each of the three claimed targets, an archive a
person unpacks and runs on a host without Nix:

asset content
felis-x86_64-linux.tar.gz relocated Nix closure: three binaries, bundled glibc and windowing libs, terminfo, README, LICENSE
felis-aarch64-darwin.tar.gz felis.app with the CLI inside it, a bin/felis launcher, terminfo, README, LICENSE; no /nix/store reference; ad-hoc signed
felis-x86_64-pc-windows-msvc.zip unchanged
felis-config.schema.json, felis.proto unchanged

felis-x86_64-linux.txt and felis-aarch64-darwin.txt are dropped. The
cache push and the verify.py --identity gates stay exactly as they are.
Notarization is out of scope.

Decision: tarball, not AppImage (deviation from the issue text)

Issue #360 says AppImage for Linux. Two facts found while grounding the
design change that:

  1. A type-2 AppImage is a FUSE mount owned by the runtime process, which
    unmounts when its direct child exits. felis's daemon is designed to
    outlive the window that spawned it (spawn.rs: "The child is not killed
    on this binary's exit; it outlives the window"). A daemon executing
    from the mount would take SIGBUS on its next page-in after the first
    window closes. Working around it means an AppRun that copies the
    payload to a persistent directory first, which is a self-extracting
    installer wearing an AppImage extension.
  2. The flake's nixpkgs has no appimagetool/appimagekit; the runtime
    would be fetched from GitHub releases.

A tarball with the same relocation gives the user the same thing (unpack,
run bin/felis), matches the Windows zip and the macOS tarball, and has no
mount lifetime. The issue body and plan comment will record this change.
If an AppImage is wanted later it is an AppRun over this exact tree.

Facts the design rests on (verified on the built package)

  • nix build .#felis on x86_64-linux: bin/felis, bin/felis-daemon
    are plain ELF; bin/felis-client is the makeWrapper shell script and
    bin/.felis-client-wrapped the ELF (package.nix postFixup).
  • ldd: all three DT_NEED only libc, libm, libgcc_s, with
    PT_INTERP /nix/store/…-glibc-2.42-84/lib/ld-linux-x86-64.so.2.
  • Strings in the client: dlopen targets are libvulkan.so.1,
    libwayland-client.so.0, libwayland-egl.so.1, libxcb.so.1,
    libX11.so.6, libX11-xcb.so.1, libXcursor.so.1, libXi.so.6,
    libxkbcommon.so.0, libxkbcommon-x11.so.0. Fontconfig is the pure
    Rust fontconfig-parser (reads /etc/fonts/fonts.conf and the XDG
    font dirs itself); nothing links libfontconfig or libfreetype.
  • Closure is 35 paths, 113 MiB; the font stack (fontconfig, freetype,
    expat, libxml2, brotli, bzip2, libpng, zlib, dejavu) is dead weight for
    the tarball.
  • The nix glibc's ld.so accepts --library-path and --argv0.
  • Daemon and frontend discovery is current_exe().parent().join(name)
    with a PATH fallback (spawn.rs sibling_daemon, felis-cli
    frontend_program); the systemd hand-off uses the sibling path or a
    PATH lookup, else forks. One more reader: the SSH relay
    (crates/felis-daemon/src/relay.rs::spawn_persistent_daemon) re-execs
    current_exe() itself as serve to start a cold daemon on the remote
    host.
  • The runner service (gitea-runner-default.service, DynamicUser
    gitea-runner) has SupplementaryGroups=docker and
    RestrictNamespaces=no; the host runs a Docker daemon. Foreign-host
    smokes can therefore run in an Ubuntu container.
  • Recursive readelf -d over the closure: libwayland-client.so.0
    NEEDs libffi.so.8; the X11 chain is libX11 → libxcb → libXau,
    libXdmcp; libXcursor → libXrender, libXfixes; libXi and libXrandr →
    libXext. libxkbcommon.so.0 embeds the store paths of
    libx11/share/X11/locale (compose tables) and xkeyboard-config, and
    honors XLOCALEDIR, XKB_CONFIG_ROOT, XKB_CONFIG_EXTRA_PATH.
  • felis sessions spawn -- <cmd> starts a cold daemon (cli.md line 560);
    felis version deliberately does not (RemoteSpawn::Refuse).
    felis daemon stop exists for cleanup.
  • macOS: make-macos-app.sh copies the client as Contents/MacOS/felis
    (CFBundleExecutable) and the daemon beside it, writes Info.plist, no
    signing, no install-name work. The CLI felis is not in the app. The
    darwin runner builds with nix; /usr/bin/codesign is part of the base
    OS, otool/install_name_tool are not guaranteed (Xcode CLT) but
    nixpkgs cctools and sigtool provide them.
  • aarch64-darwin refuses to exec an unsigned Mach-O; nixpkgs' darwin
    stdenv ad-hoc signs outputs, and install_name_tool invalidates that
    signature, so every edited file must be re-signed.

Linux: relocated closure

Layout of felis-x86_64-linux/

bin/felis            sh launcher
bin/felis-client     sh launcher
bin/felis-daemon     sh launcher
bin/.ld.so           copy of the closure's ld-linux-x86-64.so.2
libexec/felis        ELF (from $felis/bin/felis)
libexec/felis-client ELF (from $felis/bin/.felis-client-wrapped)
libexec/felis-daemon ELF
lib/                 flattened .so files of the bundled closure subset
share/X11/locale/    compose tables for libxkbcommon
share/terminfo/      compiled xterm-felis entry (as in the package)
share/bash-completion/ share/zsh/ share/fish/ share/man/
README.md LICENSE

Launcher

Each bin/<name> is:

#!/bin/sh
here=$(cd "$(dirname "$0")" && pwd)
root=$(dirname "$here")
export XLOCALEDIR="${XLOCALEDIR:-$root/share/X11/locale}"
exec "$here/.ld.so" --argv0 "$0" \
  --library-path "$root/lib:${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}/usr/lib/x86_64-linux-gnu:/lib/x86_64-linux-gnu:/usr/lib64:/lib64:/usr/lib:/lib" \
  "$root/libexec/<name>" "$@"

Why the loader is invoked directly: PT_INTERP is an absolute path and the
tarball can live anywhere; running the bundled ld.so with
--library-path makes the closure's libraries win without patchelf, and
--argv0 keeps clap's usage line and the daemon's process name honest.

Why .ld.so sits in bin/: under a direct loader exec, /proc/self/exe
is the loader, so current_exe().parent() is the loader's directory.
Placing the loader in bin/ keeps bin/felis-daemon and
bin/felis-client (the launchers) as the siblings the code already
looks for. The systemd hand-off passes the absolute launcher path,
which is fine as long as the tree is not deleted; that is the same
contract as any installed binary.

One Rust change, in the relay: spawn_persistent_daemon currently
execs current_exe(), which under the loader is .ld.so and would run
.ld.so serve …. It changes to the same rule the client uses: the
sibling felis-daemon next to current_exe() when that file exists,
else current_exe() itself. In every existing layout ($out/bin, the
Windows zip, a cargo target dir) the sibling is the very file
current_exe() names, so behavior there is unchanged; in the archive the
sibling is the launcher. A unit test covers the sibling-present and
sibling-absent cases. Not wire, not CLI surface.

Why the launcher sets exactly one variable, and only as a default: the
client snapshots its whole environment for every session it creates
(env_base.rs::capture) and the daemon hands that snapshot to PTY
children (serve.rs), so anything a launcher exports reaches the user's
shells and the host programs under them. LOCPATH and GCONV_PATH are
therefore off the table: a host glibc given LOCPATH looks only there,
and bundled gconv modules loaded into a host glibc are a crash. Locale
resolution is left to the bundled glibc's own fallback to the host's
/usr/lib/locale/locale-archive, which resolves en_US.UTF-8 on a host
that has it (the smoke asserts this). XLOCALEDIR is the one export:
libxkbcommon's compose tables come from there and nothing else can
supply them; the variable is a standard libX11 one, the data is the
upstream X11 locale tree, so a host program that inherits it reads the
same tables it would have read from /usr/share/X11/locale. A user who
set it keeps their value. xkeyboard-config is not bundled and
XKB_CONFIG_ROOT is not set: winit takes the keymap from the Wayland
compositor or the X server, never from RMLVO names. The launcher
template is the same for all three binaries.

Why host directories come after lib/: the GPU stack (libvulkan.so.1,
the ICDs it loads, libEGL/libGL) must be the host's, because drivers
are host state. Everything the host supplies is loaded into a process
running the bundled glibc 2.42, which works when the host's driver libs
were linked against a glibc no newer than that. This is the one host
assumption; install.md states it and names Vulkan as a host requirement.

Bundled closure subset

Every path of the current 35-path closure is classified; the derivation
fails if a closure path is in neither list, so a new runtime dependency
breaks nix build .#felis-dist instead of shipping or going missing.

Bundled (lib/*.so* copied into lib/): glibc (libraries only: its
lib/gconv and lib/locale are not shipped, see the launcher notes;
character conversion in the client is UTF-8 through Rust, and libX11's
XIM path needs only what setlocale resolves from the host archive),
gcc-*-libgcc (the libgcc_s.so.1 the felis binaries resolve through
gcc-*-lib, whose lib/libgcc_s.so.1 is a symlink to it; gcc-*-lib
itself is classified as bundled but contributes only that symlink, which
cp -L follows), libffi, libx11, libxcb, libxau,
libxdmcp, libxcursor, libxrender, libxfixes, libxi, libxext,
libxrandr, libxkbcommon, wayland. Plus one data tree: libx11's
share/X11/locale → share/X11/locale, pointed at by XLOCALEDIR, so
compose and dead-key input work off Nix. Fourteen of its Compose files
include the en_US.UTF-8 table by absolute store path; the copy
rewrites that prefix to %S (libX11's own token for the locale
directory, which libxkbcommon also expands), and the build then fails if
grep -r /nix/store share/ finds anything. Plus the package's own
share/terminfo, share/bash-completion, share/zsh, share/fish
and share/man trees, copied as they are.

Excluded: vulkan-loader, libglvnd (host GPU stack); fontconfig,
fontconfig-lib, freetype, expat, libxml2, brotli-lib, bzip2,
libpng-apng, zlib, zlib-static, dejavu-fonts-minimal (nothing
links the font stack); libidn2, libunistring (glibc's libnss_*
extras, unused); xgcc-*-libgcc (glibc's bootstrap libgcc_s.so.1,
a different binary from gcc's; glibc's own libraries resolve
libgcc_s.so.1 by name and get the bundled gcc one, which is what they
get on every NixOS system where gcc's is first on the path);
xkeyboard-config (keymaps arrive from the compositor or server);
bash (the wrapper's interpreter); felis itself.

Flattening policy: every file is copied with symlinks dereferenced
(cp -L); a filename that would be written twice with differing
contents fails the build (cmp before overwrite); after assembly, find -type l under the tree must show
no link whose target leaves the tree.

The derivation also verifies the bundle is closed: readelf -d NEEDED
over lib/ and libexec/ must resolve inside lib/ except for the
named host-supplied set (libvulkan.so.1, libEGL, libGL, and the
ICDs they load are dlopen targets, not NEEDED, so the NEEDED closure is
expected to be fully self-contained).

Derivation

packages.<system>.felis-dist (nix/dist.nix, callPackage with
felis), output $out/felis-<triple>.tar.gz where triple is
x86_64-linux or aarch64-darwin. Tar is built reproducibly
(--sort=name --mtime=@1 --owner=0 --group=0 --numeric-owner, gzip -n)
so a local nix build .#felis-dist yields the same bytes CI attaches.
The tree is assembled by nix/dist-linux.sh / nix/dist-macos.sh,
which also run standalone on a cargo build --release tree the way
make-macos-app.sh does, so the two paths cannot disagree.

CI

New job linux-package (runs-on nix, needs [verify-tag, linux-smoke]):
nix build .#felis-dist, extract to $RUNNER_TEMP, then:

  1. Manifest: every required path exists in the extracted tree (the
    three launchers and .ld.so, the three ELF files, lib/libc.so.6,
    share/X11/locale/compose.dir, the compiled xterm-felis entry, the
    three completion files, the felis.1 man page, README.md, LICENSE),
    and grep -l /nix/store over the three launcher scripts and
    grep -rl over share/ finds nothing. ELF files keep store strings
    (RUNPATH in the binaries, and the loader .ld.so carries its own
    compiled-in cache, locale and gconv paths), all inert under
    --library-path, so .ld.so, lib/ and libexec/ are excluded from
    the textual check; instead readelf -d over lib/ and libexec/
    must show every NEEDED resolved inside lib/, and the container smoke
    is what proves the loader works without /nix.
  2. Foreign-host smoke in a container: docker run --rm -v <tree>:/opt/felis:ro ubuntu:24.04 (glibc 2.39, no Nix), with
    apt-get install locales xvfb libvulkan1 mesa-vulkan-drivers libwayland-client0 libxkbcommon-x11-0 so the host GPU stack is a
    real foreign one
    (lavapipe over Ubuntu's own glibc, loaded into the bundled 2.42
    process: the exact mixing the design relies on). Inside it:
    a. /opt/felis/bin/felis --version.
    b. Daemon autospawn: felis --socket <tmp>/daemon.sock sessions spawn -- true (an isolated socket, as linux-smoke does), then assert the
    daemon's /proc/<pid>/exe is /opt/felis/bin/.ld.so and its
    cmdline names /opt/felis/libexec/felis-daemon. Then a PTY child's
    real environment: felis --socket … sessions spawn -- sh -c 'env > /tmp/child.env', and assert the file has no LOCPATH,
    GCONV_PATH, LD_LIBRARY_PATH or XKB_CONFIG_ROOT, and that
    XLOCALEDIR is the bundled path. Then felis --socket … daemon stop --force (a plain stop refuses while an exited session sits
    in its five-second reap grace).
    b1. Cold relay spawn, the felis ssh remote path: with /opt/felis/bin
    on PATH and a fresh socket, run felis-daemon relay --socket <tmp>/relay.sock < /dev/null (the relay exits on EOF; the daemon it
    spawned must not), then assert a daemon whose /proc/<pid>/exe is
    .ld.so is serving that socket (felis --socket … sessions list),
    then daemon stop --force.
    b2. Locale resolution under the bundled loader: after locale-gen en_US.UTF-8 ja_JP.UTF-8 in the container, run the host's own
    /usr/bin/locale charmap through the bundled loader
    (LANG=en_US.UTF-8 /opt/felis/bin/.ld.so --library-path /opt/felis/lib /usr/bin/locale charmap) and require stdout to be
    exactly UTF-8 with empty stderr: locale prints a setlocale
    diagnostic and falls back to ANSI_X3.4-1968 when the category
    data cannot be loaded, so this is a real setlocale(LC_ALL, "")
    probe against the host archive from the bundled glibc.
    b3. Compose tables off Nix: with XLOCALEDIR as the launcher sets it
    and LANG=ja_JP.UTF-8 (one of the fourteen rewritten locales),
    libxkbcommon's libexec/xkbcommon/xkbcli-compile-compose --locale ja_JP.UTF-8, run directly through the bundled loader (the
    bin/xkbcli dispatcher finds its helpers by a compiled-in store
    path, so the helper itself is what the smoke copies into its
    context; nothing of it enters the archive), must succeed and print
    a non-empty table.
    c. The GUI marker smoke from linux-smoke (xvfb-run -a /opt/felis/bin/felis-client --socket … -- cat with
    FELIS_SMOKE_MARKER/FELIS_SMOKE_TIMEOUT_MS), which proves the
    archive's client opens a window, renders through host Vulkan, and
    spawns the archive's daemon. It runs under LANG=en_US.UTF-8
    (generated in b2), the locale a desktop host actually has, and a
    GUI-created PTY child's environment is checked the same way as in
    b (the marker command is sh -c 'env > …; cat' for that run, or a
    second run with that command). linux-package therefore needs
    linux-smoke only for ordering; the marker flow runs again on the
    archive.
    The image pull and apt install are the cost; the job's timeout allows
    for them and the container is discarded.
  3. verify.py --identity against bin/felis version --format json from
    the extracted tree, so the archive's own binary, not only the store
    path, is tied to the tag.
  4. Upload artifact felis-x86_64-linux = the tarball.

linux-publish loses nothing. release needs linux-package too,
downloads its artifact, and the asset loop becomes
artifacts/felis-x86_64-linux.tar.gz, artifacts/felis-aarch64-darwin.tar.gz,
artifacts/felis-x86_64-pc-windows-msvc.zip, schema, proto. The
"record what the artifact is" step keeps the identity check and drops
the heredoc. Release notes: "Linux and macOS ship as the attached
tarballs; the same builds are on the binary cache under the flake
reference git+…?ref=refs/tags/<tag>."

PR CI builds felis-dist on both targets so a broken bundler fails
before a tag: pr.yml's build job on Linux, and darwin.yml's build
step (nix build .#felis .#felis-dist) on darwin.

macOS: self-contained felis.app plus CLI

Layout of felis-aarch64-darwin/

felis.app/Contents/MacOS/felis-client   CFBundleExecutable
felis.app/Contents/MacOS/felis-daemon
felis.app/Contents/MacOS/felis          the CLI
felis.app/Contents/Frameworks/*.dylib   only if any binary referenced /nix/store
felis.app/Contents/Info.plist
bin/felis                               sh: exec "$here/../felis.app/Contents/MacOS/felis" "$@"
share/terminfo/ share/bash-completion/ share/zsh/ share/fish/ share/man/
README.md LICENSE

Why the CLI moves into the app: felis execs felis-client and both
spawn felis-daemon by sibling lookup. The only directory that can hold
all three as siblings on macOS is Contents/MacOS; a loose bin/felis
would fall back to PATH and fail on a fresh host. Today the client is
installed as Contents/MacOS/felis, which would collide, so
make-macos-app.sh gains --cli PATH and, when given, installs the
client as felis-client, sets CFBundleExecutable to it and puts the CLI
beside it. CFBundleName/DisplayName stay felis, so Finder and the Dock
are unchanged. nix/package.nix passes --cli too so the flake's
$out/Applications/felis.app has the same shape (grep confirms nothing
in docs or hm-module names Contents/MacOS/felis).

Why bin/felis is a script and not a symlink: whether current_exe
resolves through the symlink is a std detail; a script makes the
sibling directory unambiguous.

Dylib bundling and signing (nix/dist-macos.sh)

For every Mach-O under Contents/MacOS: walk otool -L transitively,
copy each /nix/store/…/*.dylib into Contents/Frameworks (same
collision rule as Linux: a second source for one basename must be
byte-identical or the build fails), rewrite the
reference with install_name_tool -change <store> @executable_path/../Frameworks/<name>
(@loader_path/<name> for dylib-to-dylib edges), set -id on the copy,
and delete every LC_RPATH that names /nix/store
(install_name_tool -delete_rpath), read from otool -l. The new paths
are always shorter than the 32-char-hash store path they replace, so
header padding is never the limit. Signing is inside-out and the bundle
is the last mutation, with rcodesign (nixpkgs rcodesign 0.29.0 in
the pinned revision; sigtool 0.1.3 signs single Mach-O files and
cannot write a bundle's _CodeSignature/CodeResources seal):
rcodesign sign each Frameworks dylib, then each Contents/MacOS
executable, then rcodesign sign felis.app, which seals Info.plist and
the nested code. Ad-hoc (no identity) throughout. otool and
install_name_tool come from nixpkgs cctools. All of it runs inside
the derivation, so nothing depends on Xcode CLT on the runner, and
Apple's /usr/bin/codesign --verify --strict --deep in CI is the
independent check of the result. If
the walk finds no store reference, Frameworks is omitted and the step is
only the rpath scrub and re-sign.

CI

darwin-publish grows steps after the cache push: nix build .#felis-dist, extract, then:

  1. Manifest (the three executables under Contents/MacOS, Info.plist,
    bin/felis, the compiled xterm-felis entry, the three completion
    files, the felis.1 man page, README.md, LICENSE), then an
    otool -l sweep over felis.app/Contents/MacOS/* and Frameworks/*:
    no LC_LOAD_DYLIB, LC_ID_DYLIB, or LC_RPATH names /nix/store.
  2. /usr/bin/codesign --verify --strict --deep felis.app (base OS tool).
  3. felis.app/Contents/MacOS/felis-client --version run directly must
    exit 0 and print the tag's identity (felis version reports a client
    that fails to launch as unavailable and still exits 0, and
    verify.py --identity reads only the cli object, so neither would
    catch a dylib dyld cannot resolve). Then bin/felis --version, then
    felis --socket <tmp> sessions spawn -- true and a check that the
    daemon's executable is inside the extracted felis.app, then
    daemon stop --force, then verify.py --identity on bin/felis version --format json. The macOS runner cannot open a window, so the
    direct --version is the strongest client check available there; the
    Linux archive's client is exercised by the GUI marker smoke.
  4. Upload artifact felis-aarch64-darwin = the tarball (replacing the txt).

The macOS runner cannot open windows; the GUI claim rests on the user's
daily use as before. Gatekeeper: a downloaded tarball carries quarantine
and macOS propagates it on extraction, so install.md tells the user to
run xattr -dr com.apple.quarantine on the extracted tree before first
use. Right-click → Open is documented only as the app-only alternative:
it authorizes the app's main executable, not the separately signed CLI
inside it, so a user who wants bin/felis needs the xattr step. This is
the cost of no notarization and is stated, not hidden.

Docs

  • docs/reference/workspace.md: asset table and the "five files" prose
    become the new set; the darwin artifact sentence names the tarball.
  • docs/explanation/testing.md "A tag rebuilds rather than promotes" and
    the P-2 durable-distribution sentence in workspace.md: the binary cache
    remains the reproducibility story; the tarballs are what a non-Nix host
    gets, built from the same store path.
  • docs/how-to/install.md: "The Linux tarball" and "The macOS tarball"
    sections beside "The Windows deliverable": unpack, run bin/felis,
    host requirements (Vulkan driver and libvulkan on Linux; quarantine on
    macOS), terminfo (TERMINFO_DIRS or copy into ~/.terminfo). Line 133
    about verification assets is rewritten.
  • README.md lines 51-56 and docs/README.md line 32 (the install
    guide is no longer "Nix or source"): three archives, plus the cache for
    Nix users.
  • docs/how-to/update-felis.md: an archive update path: drain and
    stop the daemon, unpack the new tarball into a fresh versioned
    directory, then swap it in; never unpack over the old tree, which
    would keep removed libraries and break the macOS resource seal.
  • docs/reference/testing.md line 544: the release.yml row names "the
    two identity files"; it names the two archives instead.
  • No CHANGELOG entry: the user rewrites CHANGELOG at release time
    (standing decision for this campaign).
  • Grep sweep for .txt, identity file, store path in docs and
    workflows.

Split into PRs

  1. PR A, Linux: the relay sibling change in relay.rs with its
    test, nix/dist.nix + nix/dist-linux.sh, felis-dist
    package on Linux, linux-package job, release job asset/notes edits
    (darwin still uploads the txt in this PR; the loop lists the txt
    until PR B), install.md Linux section, README, workspace.md rows.
  2. PR B, macOS: --cli in make-macos-app.sh, nix/dist-macos.sh,
    felis-dist on darwin, darwin.yml build step, darwin-publish steps,
    drop both txt mentions, docs sections, both testing.md pages,
    update-felis.md, final grep sweep.

Each PR stays under the 800-line budget and leaves the release workflow
consistent at its merge. Both touch release.yml, so B follows A. Then
v0.1.0-rc.3 proves the five-asset set.

Acceptance criteria (issue #360, amended)

  • A tag run attaches exactly felis-x86_64-linux.tar.gz,
    felis-aarch64-darwin.tar.gz, felis-x86_64-pc-windows-msvc.zip,
    felis-config.schema.json, felis.proto.
  • The Linux tarball, in an Ubuntu container with no /nix, runs
    felis --version, spawns its own daemon (/proc/<daemon>/exe is the
    bundled loader), starts a cold daemon from the relay path, gives PTY
    children an environment free of loader and locale overrides, resolves en_US.UTF-8 from the host archive under
    the bundled glibc, and passes the GUI marker smoke through the host's
    Vulkan stack.
  • Both archives carry terminfo, the three shells' completions and the
    man pages from the package, checked by a manifest after extraction.
  • Compose tables resolve off Nix for a locale whose table includes
    another (ja_JP.UTF-8).
  • The macOS tarball shows no /nix/store in any load command or rpath,
    passes codesign --verify --strict --deep on the bundle, and runs
    felis-client --version directly, felis --version and the daemon
    spawn on the runner.
  • nix build .#felis-dist fails when the closure gains a path outside
    the two lists or when a NEEDED library is unresolved inside lib/.
  • Every page that lists release assets lists the same five names.
## Triage plan (2026-09-23) Reviewed design (eight adversarial rounds, PASS). Deviates from the body above in one point: Linux ships a relocated tarball, not an AppImage; the reasons are in the Decision section. The issue body is amended accordingly. ## Goal A tag run attaches, for each of the three claimed targets, an archive a person unpacks and runs on a host without Nix: | asset | content | |---|---| | `felis-x86_64-linux.tar.gz` | relocated Nix closure: three binaries, bundled glibc and windowing libs, terminfo, README, LICENSE | | `felis-aarch64-darwin.tar.gz` | `felis.app` with the CLI inside it, a `bin/felis` launcher, terminfo, README, LICENSE; no `/nix/store` reference; ad-hoc signed | | `felis-x86_64-pc-windows-msvc.zip` | unchanged | | `felis-config.schema.json`, `felis.proto` | unchanged | `felis-x86_64-linux.txt` and `felis-aarch64-darwin.txt` are dropped. The cache push and the `verify.py --identity` gates stay exactly as they are. Notarization is out of scope. ## Decision: tarball, not AppImage (deviation from the issue text) Issue #360 says AppImage for Linux. Two facts found while grounding the design change that: 1. A type-2 AppImage is a FUSE mount owned by the runtime process, which unmounts when its direct child exits. felis's daemon is designed to outlive the window that spawned it (spawn.rs: "The child is not killed on this binary's exit; it outlives the window"). A daemon executing from the mount would take SIGBUS on its next page-in after the first window closes. Working around it means an AppRun that copies the payload to a persistent directory first, which is a self-extracting installer wearing an AppImage extension. 2. The flake's nixpkgs has no `appimagetool`/`appimagekit`; the runtime would be fetched from GitHub releases. A tarball with the same relocation gives the user the same thing (unpack, run `bin/felis`), matches the Windows zip and the macOS tarball, and has no mount lifetime. The issue body and plan comment will record this change. If an AppImage is wanted later it is an AppRun over this exact tree. ## Facts the design rests on (verified on the built package) - `nix build .#felis` on x86_64-linux: `bin/felis`, `bin/felis-daemon` are plain ELF; `bin/felis-client` is the makeWrapper shell script and `bin/.felis-client-wrapped` the ELF (package.nix `postFixup`). - `ldd`: all three DT_NEED only `libc`, `libm`, `libgcc_s`, with PT_INTERP `/nix/store/…-glibc-2.42-84/lib/ld-linux-x86-64.so.2`. - Strings in the client: dlopen targets are `libvulkan.so.1`, `libwayland-client.so.0`, `libwayland-egl.so.1`, `libxcb.so.1`, `libX11.so.6`, `libX11-xcb.so.1`, `libXcursor.so.1`, `libXi.so.6`, `libxkbcommon.so.0`, `libxkbcommon-x11.so.0`. Fontconfig is the pure Rust `fontconfig-parser` (reads `/etc/fonts/fonts.conf` and the XDG font dirs itself); nothing links libfontconfig or libfreetype. - Closure is 35 paths, 113 MiB; the font stack (fontconfig, freetype, expat, libxml2, brotli, bzip2, libpng, zlib, dejavu) is dead weight for the tarball. - The nix glibc's `ld.so` accepts `--library-path` and `--argv0`. - Daemon and frontend discovery is `current_exe().parent().join(name)` with a PATH fallback (spawn.rs `sibling_daemon`, felis-cli `frontend_program`); the systemd hand-off uses the sibling path or a PATH lookup, else forks. One more reader: the SSH relay (`crates/felis-daemon/src/relay.rs::spawn_persistent_daemon`) re-execs `current_exe()` itself as `serve` to start a cold daemon on the remote host. - The runner service (`gitea-runner-default.service`, DynamicUser `gitea-runner`) has `SupplementaryGroups=docker` and `RestrictNamespaces=no`; the host runs a Docker daemon. Foreign-host smokes can therefore run in an Ubuntu container. - Recursive `readelf -d` over the closure: `libwayland-client.so.0` NEEDs `libffi.so.8`; the X11 chain is libX11 → libxcb → libXau, libXdmcp; libXcursor → libXrender, libXfixes; libXi and libXrandr → libXext. `libxkbcommon.so.0` embeds the store paths of `libx11/share/X11/locale` (compose tables) and `xkeyboard-config`, and honors `XLOCALEDIR`, `XKB_CONFIG_ROOT`, `XKB_CONFIG_EXTRA_PATH`. - `felis sessions spawn -- <cmd>` starts a cold daemon (cli.md line 560); `felis version` deliberately does not (`RemoteSpawn::Refuse`). `felis daemon stop` exists for cleanup. - macOS: `make-macos-app.sh` copies the client as `Contents/MacOS/felis` (CFBundleExecutable) and the daemon beside it, writes Info.plist, no signing, no install-name work. The CLI `felis` is not in the app. The darwin runner builds with nix; `/usr/bin/codesign` is part of the base OS, `otool`/`install_name_tool` are not guaranteed (Xcode CLT) but nixpkgs `cctools` and `sigtool` provide them. - aarch64-darwin refuses to exec an unsigned Mach-O; nixpkgs' darwin stdenv ad-hoc signs outputs, and `install_name_tool` invalidates that signature, so every edited file must be re-signed. ## Linux: relocated closure ### Layout of `felis-x86_64-linux/` ``` bin/felis sh launcher bin/felis-client sh launcher bin/felis-daemon sh launcher bin/.ld.so copy of the closure's ld-linux-x86-64.so.2 libexec/felis ELF (from $felis/bin/felis) libexec/felis-client ELF (from $felis/bin/.felis-client-wrapped) libexec/felis-daemon ELF lib/ flattened .so files of the bundled closure subset share/X11/locale/ compose tables for libxkbcommon share/terminfo/ compiled xterm-felis entry (as in the package) share/bash-completion/ share/zsh/ share/fish/ share/man/ README.md LICENSE ``` ### Launcher Each `bin/<name>` is: ```sh #!/bin/sh here=$(cd "$(dirname "$0")" && pwd) root=$(dirname "$here") export XLOCALEDIR="${XLOCALEDIR:-$root/share/X11/locale}" exec "$here/.ld.so" --argv0 "$0" \ --library-path "$root/lib:${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}/usr/lib/x86_64-linux-gnu:/lib/x86_64-linux-gnu:/usr/lib64:/lib64:/usr/lib:/lib" \ "$root/libexec/<name>" "$@" ``` Why the loader is invoked directly: PT_INTERP is an absolute path and the tarball can live anywhere; running the bundled `ld.so` with `--library-path` makes the closure's libraries win without patchelf, and `--argv0` keeps clap's usage line and the daemon's process name honest. Why `.ld.so` sits in `bin/`: under a direct loader exec, `/proc/self/exe` is the loader, so `current_exe().parent()` is the loader's directory. Placing the loader in `bin/` keeps `bin/felis-daemon` and `bin/felis-client` (the launchers) as the siblings the code already looks for. The systemd hand-off passes the absolute launcher path, which is fine as long as the tree is not deleted; that is the same contract as any installed binary. One Rust change, in the relay: `spawn_persistent_daemon` currently execs `current_exe()`, which under the loader is `.ld.so` and would run `.ld.so serve …`. It changes to the same rule the client uses: the sibling `felis-daemon` next to `current_exe()` when that file exists, else `current_exe()` itself. In every existing layout (`$out/bin`, the Windows zip, a cargo target dir) the sibling is the very file `current_exe()` names, so behavior there is unchanged; in the archive the sibling is the launcher. A unit test covers the sibling-present and sibling-absent cases. Not wire, not CLI surface. Why the launcher sets exactly one variable, and only as a default: the client snapshots its whole environment for every session it creates (`env_base.rs::capture`) and the daemon hands that snapshot to PTY children (`serve.rs`), so anything a launcher exports reaches the user's shells and the host programs under them. `LOCPATH` and `GCONV_PATH` are therefore off the table: a host glibc given `LOCPATH` looks only there, and bundled gconv modules loaded into a host glibc are a crash. Locale resolution is left to the bundled glibc's own fallback to the host's `/usr/lib/locale/locale-archive`, which resolves `en_US.UTF-8` on a host that has it (the smoke asserts this). `XLOCALEDIR` is the one export: libxkbcommon's compose tables come from there and nothing else can supply them; the variable is a standard libX11 one, the data is the upstream X11 locale tree, so a host program that inherits it reads the same tables it would have read from `/usr/share/X11/locale`. A user who set it keeps their value. `xkeyboard-config` is not bundled and `XKB_CONFIG_ROOT` is not set: winit takes the keymap from the Wayland compositor or the X server, never from RMLVO names. The launcher template is the same for all three binaries. Why host directories come after `lib/`: the GPU stack (`libvulkan.so.1`, the ICDs it loads, `libEGL`/`libGL`) must be the host's, because drivers are host state. Everything the host supplies is loaded into a process running the bundled glibc 2.42, which works when the host's driver libs were linked against a glibc no newer than that. This is the one host assumption; install.md states it and names Vulkan as a host requirement. ### Bundled closure subset Every path of the current 35-path closure is classified; the derivation fails if a closure path is in neither list, so a new runtime dependency breaks `nix build .#felis-dist` instead of shipping or going missing. Bundled (`lib/*.so*` copied into `lib/`): `glibc` (libraries only: its `lib/gconv` and `lib/locale` are not shipped, see the launcher notes; character conversion in the client is UTF-8 through Rust, and libX11's XIM path needs only what `setlocale` resolves from the host archive), `gcc-*-libgcc` (the `libgcc_s.so.1` the felis binaries resolve through `gcc-*-lib`, whose `lib/libgcc_s.so.1` is a symlink to it; `gcc-*-lib` itself is classified as bundled but contributes only that symlink, which `cp -L` follows), `libffi`, `libx11`, `libxcb`, `libxau`, `libxdmcp`, `libxcursor`, `libxrender`, `libxfixes`, `libxi`, `libxext`, `libxrandr`, `libxkbcommon`, `wayland`. Plus one data tree: `libx11`'s `share/X11/locale` → `share/X11/locale`, pointed at by `XLOCALEDIR`, so compose and dead-key input work off Nix. Fourteen of its `Compose` files `include` the `en_US.UTF-8` table by absolute store path; the copy rewrites that prefix to `%S` (libX11's own token for the locale directory, which libxkbcommon also expands), and the build then fails if `grep -r /nix/store share/` finds anything. Plus the package's own `share/terminfo`, `share/bash-completion`, `share/zsh`, `share/fish` and `share/man` trees, copied as they are. Excluded: `vulkan-loader`, `libglvnd` (host GPU stack); `fontconfig`, `fontconfig-lib`, `freetype`, `expat`, `libxml2`, `brotli-lib`, `bzip2`, `libpng-apng`, `zlib`, `zlib-static`, `dejavu-fonts-minimal` (nothing links the font stack); `libidn2`, `libunistring` (glibc's `libnss_*` extras, unused); `xgcc-*-libgcc` (glibc's bootstrap `libgcc_s.so.1`, a different binary from gcc's; glibc's own libraries resolve `libgcc_s.so.1` by name and get the bundled gcc one, which is what they get on every NixOS system where gcc's is first on the path); `xkeyboard-config` (keymaps arrive from the compositor or server); `bash` (the wrapper's interpreter); `felis` itself. Flattening policy: every file is copied with symlinks dereferenced (`cp -L`); a filename that would be written twice with differing contents fails the build (`cmp` before overwrite); after assembly, `find -type l` under the tree must show no link whose target leaves the tree. The derivation also verifies the bundle is closed: `readelf -d` NEEDED over `lib/` and `libexec/` must resolve inside `lib/` except for the named host-supplied set (`libvulkan.so.1`, `libEGL`, `libGL`, and the ICDs they load are dlopen targets, not NEEDED, so the NEEDED closure is expected to be fully self-contained). ### Derivation `packages.<system>.felis-dist` (nix/dist.nix, `callPackage` with `felis`), output `$out/felis-<triple>.tar.gz` where triple is `x86_64-linux` or `aarch64-darwin`. Tar is built reproducibly (`--sort=name --mtime=@1 --owner=0 --group=0 --numeric-owner`, `gzip -n`) so a local `nix build .#felis-dist` yields the same bytes CI attaches. The tree is assembled by `nix/dist-linux.sh` / `nix/dist-macos.sh`, which also run standalone on a `cargo build --release` tree the way `make-macos-app.sh` does, so the two paths cannot disagree. ### CI New job `linux-package` (runs-on nix, needs `[verify-tag, linux-smoke]`): `nix build .#felis-dist`, extract to `$RUNNER_TEMP`, then: 1. Manifest: every required path exists in the extracted tree (the three launchers and `.ld.so`, the three ELF files, `lib/libc.so.6`, `share/X11/locale/compose.dir`, the compiled `xterm-felis` entry, the three completion files, the `felis.1` man page, README.md, LICENSE), and `grep -l /nix/store` over the three launcher scripts and `grep -rl` over `share/` finds nothing. ELF files keep store strings (RUNPATH in the binaries, and the loader `.ld.so` carries its own compiled-in cache, locale and gconv paths), all inert under `--library-path`, so `.ld.so`, `lib/` and `libexec/` are excluded from the textual check; instead `readelf -d` over `lib/` and `libexec/` must show every NEEDED resolved inside `lib/`, and the container smoke is what proves the loader works without `/nix`. 2. Foreign-host smoke in a container: `docker run --rm -v <tree>:/opt/felis:ro ubuntu:24.04` (glibc 2.39, no Nix), with `apt-get install locales xvfb libvulkan1 mesa-vulkan-drivers libwayland-client0 libxkbcommon-x11-0` so the host GPU stack is a real foreign one (lavapipe over Ubuntu's own glibc, loaded into the bundled 2.42 process: the exact mixing the design relies on). Inside it: a. `/opt/felis/bin/felis --version`. b. Daemon autospawn: `felis --socket <tmp>/daemon.sock sessions spawn -- true` (an isolated socket, as linux-smoke does), then assert the daemon's `/proc/<pid>/exe` is `/opt/felis/bin/.ld.so` and its cmdline names `/opt/felis/libexec/felis-daemon`. Then a PTY child's real environment: `felis --socket … sessions spawn -- sh -c 'env > /tmp/child.env'`, and assert the file has no `LOCPATH`, `GCONV_PATH`, `LD_LIBRARY_PATH` or `XKB_CONFIG_ROOT`, and that `XLOCALEDIR` is the bundled path. Then `felis --socket … daemon stop --force` (a plain `stop` refuses while an exited session sits in its five-second reap grace). b1. Cold relay spawn, the `felis ssh` remote path: with `/opt/felis/bin` on PATH and a fresh socket, run `felis-daemon relay --socket <tmp>/relay.sock < /dev/null` (the relay exits on EOF; the daemon it spawned must not), then assert a daemon whose `/proc/<pid>/exe` is `.ld.so` is serving that socket (`felis --socket … sessions list`), then `daemon stop --force`. b2. Locale resolution under the bundled loader: after `locale-gen en_US.UTF-8 ja_JP.UTF-8` in the container, run the host's own `/usr/bin/locale charmap` through the bundled loader (`LANG=en_US.UTF-8 /opt/felis/bin/.ld.so --library-path /opt/felis/lib /usr/bin/locale charmap`) and require stdout to be exactly `UTF-8` with empty stderr: `locale` prints a `setlocale` diagnostic and falls back to `ANSI_X3.4-1968` when the category data cannot be loaded, so this is a real `setlocale(LC_ALL, "")` probe against the host archive from the bundled glibc. b3. Compose tables off Nix: with `XLOCALEDIR` as the launcher sets it and `LANG=ja_JP.UTF-8` (one of the fourteen rewritten locales), libxkbcommon's `libexec/xkbcommon/xkbcli-compile-compose --locale ja_JP.UTF-8`, run directly through the bundled loader (the `bin/xkbcli` dispatcher finds its helpers by a compiled-in store path, so the helper itself is what the smoke copies into its context; nothing of it enters the archive), must succeed and print a non-empty table. c. The GUI marker smoke from `linux-smoke` (`xvfb-run -a /opt/felis/bin/felis-client --socket … -- cat` with `FELIS_SMOKE_MARKER`/`FELIS_SMOKE_TIMEOUT_MS`), which proves the archive's client opens a window, renders through host Vulkan, and spawns the archive's daemon. It runs under `LANG=en_US.UTF-8` (generated in b2), the locale a desktop host actually has, and a GUI-created PTY child's environment is checked the same way as in b (the marker command is `sh -c 'env > …; cat'` for that run, or a second run with that command). `linux-package` therefore `needs` `linux-smoke` only for ordering; the marker flow runs again on the archive. The image pull and apt install are the cost; the job's timeout allows for them and the container is discarded. 3. `verify.py --identity` against `bin/felis version --format json` from the extracted tree, so the archive's own binary, not only the store path, is tied to the tag. 4. Upload artifact `felis-x86_64-linux` = the tarball. `linux-publish` loses nothing. `release` needs `linux-package` too, downloads its artifact, and the asset loop becomes `artifacts/felis-x86_64-linux.tar.gz`, `artifacts/felis-aarch64-darwin.tar.gz`, `artifacts/felis-x86_64-pc-windows-msvc.zip`, schema, proto. The "record what the artifact is" step keeps the identity check and drops the heredoc. Release notes: "Linux and macOS ship as the attached tarballs; the same builds are on the binary cache under the flake reference `git+…?ref=refs/tags/<tag>`." PR CI builds `felis-dist` on both targets so a broken bundler fails before a tag: `pr.yml`'s build job on Linux, and `darwin.yml`'s build step (`nix build .#felis .#felis-dist`) on darwin. ## macOS: self-contained `felis.app` plus CLI ### Layout of `felis-aarch64-darwin/` ``` felis.app/Contents/MacOS/felis-client CFBundleExecutable felis.app/Contents/MacOS/felis-daemon felis.app/Contents/MacOS/felis the CLI felis.app/Contents/Frameworks/*.dylib only if any binary referenced /nix/store felis.app/Contents/Info.plist bin/felis sh: exec "$here/../felis.app/Contents/MacOS/felis" "$@" share/terminfo/ share/bash-completion/ share/zsh/ share/fish/ share/man/ README.md LICENSE ``` Why the CLI moves into the app: `felis` execs `felis-client` and both spawn `felis-daemon` by sibling lookup. The only directory that can hold all three as siblings on macOS is `Contents/MacOS`; a loose `bin/felis` would fall back to PATH and fail on a fresh host. Today the client is installed as `Contents/MacOS/felis`, which would collide, so `make-macos-app.sh` gains `--cli PATH` and, when given, installs the client as `felis-client`, sets CFBundleExecutable to it and puts the CLI beside it. CFBundleName/DisplayName stay `felis`, so Finder and the Dock are unchanged. `nix/package.nix` passes `--cli` too so the flake's `$out/Applications/felis.app` has the same shape (grep confirms nothing in docs or hm-module names `Contents/MacOS/felis`). Why `bin/felis` is a script and not a symlink: whether `current_exe` resolves through the symlink is a std detail; a script makes the sibling directory unambiguous. ### Dylib bundling and signing (`nix/dist-macos.sh`) For every Mach-O under `Contents/MacOS`: walk `otool -L` transitively, copy each `/nix/store/…/*.dylib` into `Contents/Frameworks` (same collision rule as Linux: a second source for one basename must be byte-identical or the build fails), rewrite the reference with `install_name_tool -change <store> @executable_path/../Frameworks/<name>` (`@loader_path/<name>` for dylib-to-dylib edges), set `-id` on the copy, and delete every `LC_RPATH` that names `/nix/store` (`install_name_tool -delete_rpath`), read from `otool -l`. The new paths are always shorter than the 32-char-hash store path they replace, so header padding is never the limit. Signing is inside-out and the bundle is the last mutation, with `rcodesign` (nixpkgs `rcodesign` 0.29.0 in the pinned revision; `sigtool` 0.1.3 signs single Mach-O files and cannot write a bundle's `_CodeSignature/CodeResources` seal): `rcodesign sign` each Frameworks dylib, then each `Contents/MacOS` executable, then `rcodesign sign felis.app`, which seals Info.plist and the nested code. Ad-hoc (no identity) throughout. `otool` and `install_name_tool` come from nixpkgs `cctools`. All of it runs inside the derivation, so nothing depends on Xcode CLT on the runner, and Apple's `/usr/bin/codesign --verify --strict --deep` in CI is the independent check of the result. If the walk finds no store reference, Frameworks is omitted and the step is only the rpath scrub and re-sign. ### CI `darwin-publish` grows steps after the cache push: `nix build .#felis-dist`, extract, then: 1. Manifest (the three executables under `Contents/MacOS`, Info.plist, `bin/felis`, the compiled `xterm-felis` entry, the three completion files, the `felis.1` man page, README.md, LICENSE), then an `otool -l` sweep over `felis.app/Contents/MacOS/*` and `Frameworks/*`: no `LC_LOAD_DYLIB`, `LC_ID_DYLIB`, or `LC_RPATH` names `/nix/store`. 2. `/usr/bin/codesign --verify --strict --deep felis.app` (base OS tool). 3. `felis.app/Contents/MacOS/felis-client --version` run directly must exit 0 and print the tag's identity (`felis version` reports a client that fails to launch as `unavailable` and still exits 0, and `verify.py --identity` reads only the `cli` object, so neither would catch a dylib dyld cannot resolve). Then `bin/felis --version`, then `felis --socket <tmp> sessions spawn -- true` and a check that the daemon's executable is inside the extracted `felis.app`, then `daemon stop --force`, then `verify.py --identity` on `bin/felis version --format json`. The macOS runner cannot open a window, so the direct `--version` is the strongest client check available there; the Linux archive's client is exercised by the GUI marker smoke. 4. Upload artifact `felis-aarch64-darwin` = the tarball (replacing the txt). The macOS runner cannot open windows; the GUI claim rests on the user's daily use as before. Gatekeeper: a downloaded tarball carries quarantine and macOS propagates it on extraction, so install.md tells the user to run `xattr -dr com.apple.quarantine` on the extracted tree before first use. Right-click → Open is documented only as the app-only alternative: it authorizes the app's main executable, not the separately signed CLI inside it, so a user who wants `bin/felis` needs the xattr step. This is the cost of no notarization and is stated, not hidden. ## Docs - `docs/reference/workspace.md`: asset table and the "five files" prose become the new set; the darwin artifact sentence names the tarball. - `docs/explanation/testing.md` "A tag rebuilds rather than promotes" and the P-2 durable-distribution sentence in workspace.md: the binary cache remains the reproducibility story; the tarballs are what a non-Nix host gets, built from the same store path. - `docs/how-to/install.md`: "The Linux tarball" and "The macOS tarball" sections beside "The Windows deliverable": unpack, run `bin/felis`, host requirements (Vulkan driver and libvulkan on Linux; quarantine on macOS), terminfo (`TERMINFO_DIRS` or copy into `~/.terminfo`). Line 133 about verification assets is rewritten. - `README.md` lines 51-56 and `docs/README.md` line 32 (the install guide is no longer "Nix or source"): three archives, plus the cache for Nix users. - `docs/how-to/update-felis.md`: an archive update path: drain and stop the daemon, unpack the new tarball into a fresh versioned directory, then swap it in; never unpack over the old tree, which would keep removed libraries and break the macOS resource seal. - `docs/reference/testing.md` line 544: the `release.yml` row names "the two identity files"; it names the two archives instead. - No CHANGELOG entry: the user rewrites CHANGELOG at release time (standing decision for this campaign). - Grep sweep for `.txt`, `identity file`, `store path` in docs and workflows. ## Split into PRs 1. **PR A, Linux**: the relay sibling change in `relay.rs` with its test, `nix/dist.nix` + `nix/dist-linux.sh`, `felis-dist` package on Linux, `linux-package` job, release job asset/notes edits (darwin still uploads the txt in this PR; the loop lists the txt until PR B), install.md Linux section, README, workspace.md rows. 2. **PR B, macOS**: `--cli` in `make-macos-app.sh`, `nix/dist-macos.sh`, `felis-dist` on darwin, `darwin.yml` build step, darwin-publish steps, drop both txt mentions, docs sections, both testing.md pages, update-felis.md, final grep sweep. Each PR stays under the 800-line budget and leaves the release workflow consistent at its merge. Both touch release.yml, so B follows A. Then `v0.1.0-rc.3` proves the five-asset set. ## Acceptance criteria (issue #360, amended) - A tag run attaches exactly `felis-x86_64-linux.tar.gz`, `felis-aarch64-darwin.tar.gz`, `felis-x86_64-pc-windows-msvc.zip`, `felis-config.schema.json`, `felis.proto`. - The Linux tarball, in an Ubuntu container with no `/nix`, runs `felis --version`, spawns its own daemon (`/proc/<daemon>/exe` is the bundled loader), starts a cold daemon from the relay path, gives PTY children an environment free of loader and locale overrides, resolves `en_US.UTF-8` from the host archive under the bundled glibc, and passes the GUI marker smoke through the host's Vulkan stack. - Both archives carry terminfo, the three shells' completions and the man pages from the package, checked by a manifest after extraction. - Compose tables resolve off Nix for a locale whose table includes another (`ja_JP.UTF-8`). - The macOS tarball shows no `/nix/store` in any load command or rpath, passes `codesign --verify --strict --deep` on the bundle, and runs `felis-client --version` directly, `felis --version` and the daemon spawn on the runner. - `nix build .#felis-dist` fails when the closure gains a path outside the two lists or when a NEEDED library is unresolved inside `lib/`. - Every page that lists release assets lists the same five names.
Author
Owner

Smoking PR #361 surfaced #362: a default-config client fails with Font(NoFont) on Debian/Ubuntu. The archive smoke names a family as a stopgap.

Smoking PR #361 surfaced #362: a default-config client fails with Font(NoFont) on Debian/Ubuntu. The archive smoke names a family as a stopgap.
Author
Owner

v0.1.0-rc.5 (run 4234) published with exactly the five assets: felis-x86_64-linux.tar.gz (21 MB), felis-aarch64-darwin.tar.gz (10 MB), felis-x86_64-pc-windows-msvc.zip, felis-config.schema.json, felis.proto. No txt. Both archive gates ran on the tag: the Linux tarball passed the Ubuntu 24.04 container smoke (CLI, daemon autospawn, relay spawn, PTY child environment, locale and compose probes, GUI marker through lavapipe); the macOS tarball passed the manifest, codesign --verify --strict --deep, the direct client launch, the launcher, the daemon spawn and the client identity gate.

Follow-ups landed on the way: #364 (terminfo directory spelling on darwin, docker client on the runner's PATH), #365 (launcher root without ..). Open: #362 (default-config font failure on Debian/Ubuntu; the archive smoke names a family as a stopgap).

`v0.1.0-rc.5` (run 4234) published with exactly the five assets: `felis-x86_64-linux.tar.gz` (21 MB), `felis-aarch64-darwin.tar.gz` (10 MB), `felis-x86_64-pc-windows-msvc.zip`, `felis-config.schema.json`, `felis.proto`. No txt. Both archive gates ran on the tag: the Linux tarball passed the Ubuntu 24.04 container smoke (CLI, daemon autospawn, relay spawn, PTY child environment, locale and compose probes, GUI marker through lavapipe); the macOS tarball passed the manifest, `codesign --verify --strict --deep`, the direct client launch, the launcher, the daemon spawn and the client identity gate. Follow-ups landed on the way: #364 (terminfo directory spelling on darwin, docker client on the runner's PATH), #365 (launcher root without `..`). Open: #362 (default-config font failure on Debian/Ubuntu; the archive smoke names a family as a stopgap).
Commenting is not possible because the repository is archived.
No description provided.