ci: ship a runnable aarch64-darwin archive with each release #363

Merged
natsukium merged 5 commits from release/issue-360-macos-archive into main 2026-09-23 06:12:07 +09:00
Owner

The macOS half of #360, on top of the Linux half in #361. A tag now attaches
felis-aarch64-darwin.tar.gz and the release's five assets are two tarballs, the
Windows zip, the config schema and the proto. felis-aarch64-darwin.txt is gone:
the identity it carried is checked in CI against the archive's own bin/felis,
which is stronger than a text file naming a store path a non-Nix user cannot fetch.

What changed

  • nix/make-macos-app.sh takes --cli PATH. With it the client installs as
    Contents/MacOS/felis-client (CFBundleExecutable follows), the CLI as
    Contents/MacOS/felis, and the daemon beside them: that is the only directory
    that can hold all three as the siblings spawn.rs looks for. Without it the
    behavior is unchanged, so a standalone cargo build --release keeps working.
    nix/package.nix passes --cli, so $out/Applications/felis.app has the same
    shape. CFBundleName and CFBundleDisplayName stay felis.
  • nix/dist-macos.sh assembles the tree: felis.app, a bin/felis script that
    execs into it, share/{terminfo,bash-completion,zsh,fish,man}, README, LICENSE.
    It walks otool -l transitively from the three executables, copies every
    /nix/store dylib into Contents/Frameworks (a second source for one basename
    must be byte-identical or the build fails), rewrites the reference to
    @executable_path/../Frameworks/<name> from an executable and
    @loader_path/<name> between dylibs, sets -id on each copy, deletes every
    store LC_RPATH, then signs inside-out with rcodesign (dylibs, executables,
    bundle). It fails the build if any LC_LOAD_DYLIB, LC_ID_DYLIB or LC_RPATH
    under the app still names /nix/store.
  • nix/dist.nix gains a darwin branch (cctools and rcodesign in
    nativeBuildInputs) beside the Linux one, sharing the reproducible tar step.
    flake.nix exports packages.felis-dist on every system instead of Linux only.
  • darwin.yml's build step builds .#felis-dist too, so a broken bundler fails
    before a tag.
  • release.yml darwin-publish: after the cache push it builds and unpacks the
    archive, runs tools/release/macos-archive-manifest.sh (required paths, no store
    path in bin/felis or share/, the otool -l sweep) and
    tools/release/macos-archive-smoke.sh (the bundled felis-client --version run
    directly, bin/felis --version, sessions spawn -- true on an isolated socket
    with the daemon's command line checked to be inside the extracted app,
    daemon stop --force), plus /usr/bin/codesign --verify --strict --deep and
    verify.py --identity on the archive's own bin/felis. Timeout raised to 90
    minutes to match linux-package. The release job attaches the tarball and the
    notes name both archives and the quarantine step.
  • Docs: install.md "The macOS archive", update-felis.md's archive path,
    workspace.md's darwin-publish prose, asset row and P-2 sentence,
    reference/testing.md's release.yml row, explanation/testing.md "A tag rebuilds
    rather than promotes", README.md and docs/README.md. Repository-wide sweep for
    .txt, identity file and store path came back clean.

Two deviations worth naming. The checks live in tools/release/*.sh rather than
workflow heredocs, following #361. otool comes from
nix shell --inputs-from . nixpkgs#cctools, because the macOS runner has no Xcode
Command Line Tools and /usr/bin/otool may not exist there.

Verified here

  • just fmt-check lint prose-check docs-links: all pass.
  • bash -n / sh -n on every new and edited script; shellcheck -x clean except
    one info-level SC2009 (the smoke needs the full command line, and macOS pgrep
    cannot print it).
  • nix eval of felis-dist.name and .drvPath on aarch64-darwin,
    x86_64-linux and aarch64-linux: the darwin derivation instantiates fully, so
    cctools and rcodesign resolve in the flake's own nixpkgs.
  • nix flake show --all-systems: felis-dist on all three systems.
  • nix build .#felis-dist on x86_64-linux still produces the Linux tarball after
    the dist.nix restructure.
  • actionlint on both workflows: only the pre-existing Forgejo noise (the nix
    runner label, enable-openid-connect).
  • make-macos-app.sh with and without --cli, run here: the layout and
    CFBundleExecutable are as described in each case.
  • dist-macos.sh driven against stub otool/install_name_tool/rcodesign and
    fake Mach-O files: the transitive walk copies both dylibs, rewrites the
    executables to @executable_path/../Frameworks/… and the dylib-to-dylib edge to
    @loader_path/…, sets the ids, drops the store rpaths, and fails as intended on
    a colliding basename and on a surviving store reference.
    macos-archive-manifest.sh passes on a good tree and fails on a missing file and
    on a store path in a load command.

What only darwin can verify

I am on Linux, so none of the real macOS behavior ran here. The darwin-build job
is the first to exercise nix build .#felis-dist with real otool,
install_name_tool and rcodesign against real Mach-O binaries, and the
v0.1.0-rc.3 tag run is the first to exercise darwin-publish: the
codesign --verify --strict --deep result, whether dyld resolves the rewritten
bundle, the daemon spawn from the extracted tree, and the five-asset release page.
The client opening a window on macOS still rests on maintainer use; the runner has
no Aqua session.

Notarization, aarch64-linux and anything Linux stay out of scope.

Closes #360

The macOS half of #360, on top of the Linux half in #361. A tag now attaches `felis-aarch64-darwin.tar.gz` and the release's five assets are two tarballs, the Windows zip, the config schema and the proto. `felis-aarch64-darwin.txt` is gone: the identity it carried is checked in CI against the archive's own `bin/felis`, which is stronger than a text file naming a store path a non-Nix user cannot fetch. ## What changed - `nix/make-macos-app.sh` takes `--cli PATH`. With it the client installs as `Contents/MacOS/felis-client` (CFBundleExecutable follows), the CLI as `Contents/MacOS/felis`, and the daemon beside them: that is the only directory that can hold all three as the siblings `spawn.rs` looks for. Without it the behavior is unchanged, so a standalone `cargo build --release` keeps working. `nix/package.nix` passes `--cli`, so `$out/Applications/felis.app` has the same shape. CFBundleName and CFBundleDisplayName stay `felis`. - `nix/dist-macos.sh` assembles the tree: `felis.app`, a `bin/felis` script that execs into it, `share/{terminfo,bash-completion,zsh,fish,man}`, README, LICENSE. It walks `otool -l` transitively from the three executables, copies every `/nix/store` dylib into `Contents/Frameworks` (a second source for one basename must be byte-identical or the build fails), rewrites the reference to `@executable_path/../Frameworks/<name>` from an executable and `@loader_path/<name>` between dylibs, sets `-id` on each copy, deletes every store `LC_RPATH`, then signs inside-out with `rcodesign` (dylibs, executables, bundle). It fails the build if any `LC_LOAD_DYLIB`, `LC_ID_DYLIB` or `LC_RPATH` under the app still names `/nix/store`. - `nix/dist.nix` gains a darwin branch (`cctools` and `rcodesign` in `nativeBuildInputs`) beside the Linux one, sharing the reproducible tar step. `flake.nix` exports `packages.felis-dist` on every system instead of Linux only. - `darwin.yml`'s build step builds `.#felis-dist` too, so a broken bundler fails before a tag. - `release.yml` `darwin-publish`: after the cache push it builds and unpacks the archive, runs `tools/release/macos-archive-manifest.sh` (required paths, no store path in `bin/felis` or `share/`, the `otool -l` sweep) and `tools/release/macos-archive-smoke.sh` (the bundled `felis-client --version` run directly, `bin/felis --version`, `sessions spawn -- true` on an isolated socket with the daemon's command line checked to be inside the extracted app, `daemon stop --force`), plus `/usr/bin/codesign --verify --strict --deep` and `verify.py --identity` on the archive's own `bin/felis`. Timeout raised to 90 minutes to match `linux-package`. The `release` job attaches the tarball and the notes name both archives and the quarantine step. - Docs: install.md "The macOS archive", update-felis.md's archive path, workspace.md's `darwin-publish` prose, asset row and P-2 sentence, reference/testing.md's `release.yml` row, explanation/testing.md "A tag rebuilds rather than promotes", README.md and docs/README.md. Repository-wide sweep for `.txt`, `identity file` and `store path` came back clean. Two deviations worth naming. The checks live in `tools/release/*.sh` rather than workflow heredocs, following #361. `otool` comes from `nix shell --inputs-from . nixpkgs#cctools`, because the macOS runner has no Xcode Command Line Tools and `/usr/bin/otool` may not exist there. ## Verified here - `just fmt-check lint prose-check docs-links`: all pass. - `bash -n` / `sh -n` on every new and edited script; `shellcheck -x` clean except one info-level SC2009 (the smoke needs the full command line, and macOS `pgrep` cannot print it). - `nix eval` of `felis-dist.name` and `.drvPath` on `aarch64-darwin`, `x86_64-linux` and `aarch64-linux`: the darwin derivation instantiates fully, so `cctools` and `rcodesign` resolve in the flake's own nixpkgs. - `nix flake show --all-systems`: `felis-dist` on all three systems. - `nix build .#felis-dist` on x86_64-linux still produces the Linux tarball after the dist.nix restructure. - `actionlint` on both workflows: only the pre-existing Forgejo noise (the `nix` runner label, `enable-openid-connect`). - `make-macos-app.sh` with and without `--cli`, run here: the layout and CFBundleExecutable are as described in each case. - `dist-macos.sh` driven against stub `otool`/`install_name_tool`/`rcodesign` and fake Mach-O files: the transitive walk copies both dylibs, rewrites the executables to `@executable_path/../Frameworks/…` and the dylib-to-dylib edge to `@loader_path/…`, sets the ids, drops the store rpaths, and fails as intended on a colliding basename and on a surviving store reference. `macos-archive-manifest.sh` passes on a good tree and fails on a missing file and on a store path in a load command. ## What only darwin can verify I am on Linux, so none of the real macOS behavior ran here. The `darwin-build` job is the first to exercise `nix build .#felis-dist` with real `otool`, `install_name_tool` and `rcodesign` against real Mach-O binaries, and the `v0.1.0-rc.3` tag run is the first to exercise `darwin-publish`: the `codesign --verify --strict --deep` result, whether dyld resolves the rewritten bundle, the daemon spawn from the extracted tree, and the five-asset release page. The client opening a window on macOS still rests on maintainer use; the runner has no Aqua session. Notarization, `aarch64-linux` and anything Linux stay out of scope. Closes #360
ci: ship a runnable aarch64-darwin archive with each release
Some checks failed
darwin / build felis (aarch64-darwin) (pull_request) Failing after 55s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m41s
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 2m3s
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
pr / nix flake check (pull_request) Successful in 24s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
99874c0782
A macOS user without Nix had nothing to install: the release attached a
text file naming a store path they could not fetch. They now get the
same tarball shape the other two targets ship, and the identity the txt
carried is checked in CI against the archive's own binary instead, so
dropping the file loses no evidence.

Self-contained means three things on macOS. The CLI moves into
Contents/MacOS, because felis execs felis-client and both find
felis-daemon beside themselves, and that is the only directory able to
hold all three; the client takes the felis-client name there and
CFBundleExecutable follows it, so Finder and the Dock are unchanged.
Every store dylib the binaries reach is copied into Contents/Frameworks
with its load command rewritten, since a host without Nix has nothing
under /nix/store and dyld fails the exec rather than falling back. Every
edited Mach-O is signed again, because aarch64-darwin refuses to run an
unsigned one and install_name_tool invalidates what nixpkgs wrote.

rcodesign rather than sigtool: only the former seals a bundle's
_CodeSignature/CodeResources. Notarization stays out, which is why
install.md tells the user to clear the quarantine attribute over the
whole tree; right-click to open authorizes the app's main executable
alone and leaves bin/felis unrunnable.

Closes #360

Assisted-by: Claude Code
nix: point the macOS assembler at the bundle script explicitly
Some checks failed
darwin / build felis (aarch64-darwin) (pull_request) Successful in 59s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
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 2m2s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
f62838c911
Nix copies every script in the derivation into the store on its own, so
the sibling lookup that works in a checkout resolves to /nix/store with
no basename attached and the build dies with a 127. The path comes in as
a flag instead, which the standalone run still defaults from the script's
own directory.

Assisted-by: Claude Code
ci: let the macOS smoke report a missing daemon instead of dying silently
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
pr / nix flake check (pull_request) Successful in 24s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m42s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m19s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
bench / Criterion regression gate (pull_request) Failing after 2m3s
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 3m47s
windows / frontend smoke (Windows) (pull_request) Successful in 1m35s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
765d9de8f7
A grep that matches nothing fails, and under set -e the assignment takes
the whole script with it before the case that explains what went wrong
ever runs. The reader would see an exit code and no reason.

Assisted-by: Claude Code
Author
Owner

The darwin CI run on this branch built .#felis-dist on the real runner and
passed: the walk found libiconv.2.dylib and libcharset.1.dylib in the
closure, copied them into Contents/Frameworks, rewrote the load commands, and
rcodesign sealed the bundle inside-out, with the build-time
"no /nix/store in any load command" check clean. That covers everything in
this PR except the darwin-publish steps, which only a tag run reaches:
codesign --verify --strict --deep, the archive's client and daemon actually
running, and the five-asset release page.

The Criterion regression gate is red on this branch, as it was on the first
push. The diff contains no Rust (16 files: nix, workflows, shell scripts and
docs), so the regressed parser and socket benches are the shared runner's load,
not this change.

The darwin CI run on this branch built `.#felis-dist` on the real runner and passed: the walk found `libiconv.2.dylib` and `libcharset.1.dylib` in the closure, copied them into `Contents/Frameworks`, rewrote the load commands, and `rcodesign` sealed the bundle inside-out, with the build-time "no `/nix/store` in any load command" check clean. That covers everything in this PR except the `darwin-publish` steps, which only a tag run reaches: `codesign --verify --strict --deep`, the archive's client and daemon actually running, and the five-asset release page. The Criterion regression gate is red on this branch, as it was on the first push. The diff contains no Rust (16 files: nix, workflows, shell scripts and docs), so the regressed parser and socket benches are the shared runner's load, not this change.
ci: close the gaps a review found in the macOS archive gate
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 2m2s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m5s
pr / nix flake check (pull_request) Successful in 24s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m43s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
windows / cargo nextest (Windows) (pull_request) Successful in 3m46s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 55s
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 / frontend smoke (Windows) (pull_request) Successful in 1m32s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
0a17fc0b4c
Four of these let a bad archive through, and one left the matrix
disagreeing with what a tag attaches.

The smoke created the socket's parent with `mkdir -p`, which takes the
inherited umask, and felis-transport refuses a parent whose mode is not
exactly 0700; the daemon creates it correctly by itself, so the script
no longer creates it at all. The socket path was also fixed, and the
runner is persistent: a daemon left by a failed run would be adopted by
the next one and satisfy the command-line check, because the workspace
path it was spawned from is the same. Each run now gets its own mktemp
directory and removes it, and the daemon it started, on exit.

`felis-client --version` proves only that dyld can start the binary,
and the identity gate reads the CLI object alone, so a client from
another revision passed both. The release job now also requires
`client_status` to be ok and the client's identity to equal the CLI's,
which verify.py has already tied to the tag.

The store-path checks covered bin/, share/ and selected load commands,
leaving Info.plist and anything a later tree gains unexamined. They now
sweep every non-Mach-O file with a binary-safe grep. Mach-O files stay
on the load-command sweep alone: a Rust binary carries store strings no
loader reads, so scanning their content could only produce false
alarms.

The smoke stays outside `nix develop` throughout: the dev shell exports
DYLD_FALLBACK_LIBRARY_PATH, which could resolve a dylib the bundle
failed to carry and turn the check that proves the relocation into a
pass.

Assisted-by: Claude Code
ci: run the macOS manifest under a bash that has mapfile
Some checks failed
bench / Criterion regression gate (pull_request) Failing after 2m52s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m54s
pr / nix flake check (pull_request) Successful in 3m39s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m40s
pr / wire schema is compatible with the base (pull_request) Successful in 18s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 2m54s
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 nextest (Windows) (pull_request) Successful in 3m48s
windows / frontend smoke (Windows) (pull_request) Successful in 1m38s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 1m14s
c42301e439
The step asked the runner's PATH for `bash`, and macOS still ships 3.2
there, so the script would have died on `mapfile` before checking
anything and taken every tag release with it. The `nix shell` that
already supplies `otool` now supplies bash too, and the script says so
itself for a run started by hand.

The smoke beside it needs nothing of the sort: it is POSIX, the
workflow runs it with `sh`, and it stays outside the dev shell on
purpose.

install.md and the darwin.yml row in testing.md described a world with
no macOS archive in it.

Assisted-by: Claude Code
natsukium deleted branch release/issue-360-macos-archive 2026-09-23 06:12:07 +09:00
Commenting is not possible because the repository is archived.
No description provided.