ci: bump nix-bundle-dir to main, gate the .app on a macOS smoke test (#5)

* chore: bump nix-bundle-dir to main (cb9afc8)

Two revisions behind: 0dc73e8ec -> cb9afc8. The headline work in that range
is Linux-only (the ld.so trampoline is gone, every bundled ELF now gets the
interpreter its psABI mandates, DT_RPATH replaces DT_RUNPATH, the
LD_PRELOAD /proc/self/exe shim is deleted, and the launcher is emitted only
when a bundle needs an env var its libraries cannot derive), so the
expectation was that this bump is a no-op here.

It is not quite. c88566a ("fix rpaths") gives every Mach-O a bare
@loader_path LC_RPATH in addition to @loader_path/../lib, so a plugin staged
outside Contents/Frameworks can find the companion dylibs sitting next to
it. That is the only difference in the .app built from a plain subject:
same file list, one extra load command, hence new signatures. 061a9a8 and
adea897 can add payload too (Contents/libexec/QtWebEngineProcess,
resources/, translations/ for QtWebEngine apps; share/X11/xkb where
libxkbcommon is bundled), though neither is reachable from a non-Qt subject.

The new guiApp knob is Linux-only, and the qtCliApp bundler it adds is
mirrored here automatically by the bundlers map.

* ci: assert the .app contract on a macOS runner

This repo produces the artifact a user double-clicks and had no automated
gate at all. Its failure modes are the ones `nix build` cannot see: the
derivation succeeds whether or not the executable is where Info.plist says,
whether or not the plist parses, and whether or not the binaries still
resolve their libraries out of /nix/store -- which works on the machine that
built them and on no machine that did not.

tests/smoke.sh builds an .app around nixpkgs#hello (tiny, cached, and still
links a non-system dylib so the linkage rewrite is real), asserts the
contract, and runs it.

Two assertions are worth calling out.

"Every dylib dep resolves to a file inside the .app" exists because the
obvious check is a trap: delete Contents/lib and the app still prints its
greeting, because dyld quietly falls back to the shared cache's
/usr/lib/libiconv.2.dylib. Naming deps @loader_path/... proves nothing on
its own; the names have to land on files that are actually in the bundle.

"qtCliApp and qtApp produce identical .app contents" pins down the claim
that guiApp is a Linux-only knob. If a Linux-only flag ever starts changing
macOS output, that is where it shows up.

Each assertion was checked against the pre-bump tree or against a
deliberately broken bundler: dropping the Contents/lib symlink, pointing the
wrapper at a missing binary, malforming the plist, and copying the unbundled
derivation instead of the bundle each turn the relevant check red.

* ci: use DeterminateSystems nix installer on macos-latest

cachix/install-nix-action@v27 fails on current macos-latest runners with
`_nixbld1 already exists` / eDSRecordAlreadyExists during the build-user
setup. Same switch basecamp and the design-system already made for their
Darwin jobs.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dario Lipicar
2026-08-03 20:02:17 -03:00
committed by GitHub
co-authored by Cursor
parent d6b0cc518e
commit 140f4ec32e
4 changed files with 300 additions and 3 deletions
+49
View File
@@ -0,0 +1,49 @@
name: CI
# This repo had no automated gate at all, which is a poor fit for what it does:
# it produces the .app a user double-clicks, and its failure modes are exactly
# the ones `nix build` cannot see. The derivation succeeds whether or not the
# executable ends up where Info.plist says it is, whether or not the plist
# parses, and — the one that actually ships — whether or not the binaries still
# resolve their libraries out of /nix/store, which works on every machine that
# built them and on no machine that did not.
#
# tests/smoke.sh builds an .app from nixpkgs#hello and asserts that contract,
# then runs it. It must run on a real macOS runner: otool, plutil, PlistBuddy,
# codesign and the ability to exec a Mach-O are all part of what is being
# checked, and none of them exist on Linux.
on:
push:
branches: [main]
pull_request:
jobs:
smoke:
name: .app contract (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# macos-latest is arm64. x86_64-darwin is also a declared system for
# this flake; covering it needs an Intel runner label, which GitHub is
# in the process of retiring, so it is left out rather than pinned to a
# label that will disappear.
os: [macos-latest]
steps:
- uses: actions/checkout@v4
# cachix/install-nix-action fails on current macos-latest runners
# (_nixbld1 already exists). Same choice basecamp and the design-system
# made for their Darwin jobs.
- uses: DeterminateSystems/nix-installer-action@v22
- uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
continue-on-error: true # a fork without the secret still gets to run
- name: .app contract
run: bash tests/smoke.sh .
Generated
+3 -3
View File
@@ -44,11 +44,11 @@
]
},
"locked": {
"lastModified": 1776108083,
"narHash": "sha256-gGmOUYksc/Gnso7dHGQ1DoB9QKsBGjQ/WUIHKqBbvuM=",
"lastModified": 1785790585,
"narHash": "sha256-Twwl3any+BHvTlEuKXcbr2eGny+BLO4oj8AxfKH40Xo=",
"owner": "logos-co",
"repo": "nix-bundle-dir",
"rev": "0dc73e8ec027420c55f6d8d7236c6adb8d68a159",
"rev": "cb9afc8c8c8a0037cf27f7467132f9e01e476588",
"type": "github"
},
"original": {
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>hello</string>
<key>CFBundleIdentifier</key>
<string>co.logos.smoke.hello</string>
<key>CFBundleName</key>
<string>SmokeHello</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>smoke.icns</string>
<key>CFBundleShortVersionString</key>
<string>@VERSION@</string>
<key>CFBundleVersion</key>
<string>@BUILD_NUMBER@</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
</dict>
</plist>
Executable
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env bash
# Smoke test for the macOS .app contract.
#
# This repo is the last step before an artifact reaches a user, and the ways it
# breaks are invisible to `nix build`: the derivation happily succeeds while
# producing an .app whose executable is missing, whose Info.plist is malformed,
# or — the classic — whose binary still resolves its libraries out of
# /nix/store and therefore runs only on the machine that built it. Nothing
# here checks that the code is nice; every assertion is something a user would
# hit as "the app does not open".
#
# Subject: nixpkgs#hello, chosen because it is tiny, cached, and still links a
# non-system dylib (libiconv) — so the linkage rewrite it exercises is real.
# It is wrapped with the library entry point (lib.mkMacOSApp) rather than the
# `bundlers` mirror because the mirror insists the derivation ship its own
# icon and Info.plist; the test provides those from tests/ instead.
#
# Usage: tests/smoke.sh [flake-ref] (default: the checkout, ".")
set -uo pipefail
FLAKE="${1:-.}"
# Absolutise a local flake ref BEFORE cd-ing into the scratch dir, or "." would
# resolve to the scratch dir and nix would report "could not find a flake.nix".
# A ref containing ':' is a URL (github:, git+https:, path:) and is left alone.
case "$FLAKE" in
*:*) ;;
*) FLAKE="$(cd "$FLAKE" 2>/dev/null && pwd)" || { echo "smoke: no such flake dir: ${1:-.}" >&2; exit 1; } ;;
esac
# The Info.plist template belongs to this script, not to the flake under test,
# so resolve it from the script's own directory — also before the cd.
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLIST="$HERE/Info.plist.in"
[ -f "$PLIST" ] || { echo "smoke: missing $PLIST" >&2; exit 1; }
if [ "$(uname -s)" != "Darwin" ]; then
echo "smoke: this bundler only produces .app bundles on macOS; nothing to assert"
exit 0
fi
WORK="$(mktemp -d)"
trap 'chmod -R u+w "$WORK" 2>/dev/null; rm -rf "$WORK"' EXIT
cd "$WORK"
pass=0; fail=0
ok() { printf ' \033[32mok\033[0m %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; fail=$((fail+1)); }
check(){ if eval "$2" >/dev/null 2>&1; then ok "$1"; else bad "$1" "${3:-}"; fi; }
SYSTEM="$(nix eval --impure --raw --expr builtins.currentSystem)"
# Print any dependency of $1 that does NOT land on a file inside the bundle.
# Names are resolved the way dyld would: @loader_path / @executable_path
# against the Mach-O's own directory, @rpath against each LC_RPATH. Real
# system libraries are skipped. (Note for the next reader: this is a function
# rather than an inline $( ) block because bash 3.2 — still what macOS and the
# GitHub macOS runners ship — mis-parses `case` inside command substitution.)
unresolved_deps() {
local macho="$1" dir dep rp try cand
dir="$(dirname "$macho")"
otool -L "$macho" | tail -n +2 | awk '{print $1}' | while IFS= read -r dep; do
cand=""
case "$dep" in
/usr/lib/*|/System/*) continue ;;
@loader_path/*) cand="$dir/${dep#@loader_path/}" ;;
@executable_path/*) cand="$dir/${dep#@executable_path/}" ;;
@rpath/*)
for rp in $(otool -l "$macho" | awk '/LC_RPATH/{f=1} f && $1=="path"{print $2; f=0}'); do
case "$rp" in
@loader_path*) try="$dir/${rp#@loader_path}/${dep#@rpath/}" ;;
@executable_path*) try="$dir/${rp#@executable_path}/${dep#@rpath/}" ;;
*) try="$rp/${dep#@rpath/}" ;;
esac
if [ -e "$try" ]; then cand="$try"; break; fi
done
;;
esac
if [ -z "$cand" ] || [ ! -e "$cand" ]; then echo "$dep"; fi
done
}
# Wrap nixpkgs#hello into an .app using one of nix-bundle-dir's bundlers.
build_app() { # <nix-bundle-dir bundler attr> <out-link>
nix build --impure --print-build-logs -o "$2" --expr "
let
flake = builtins.getFlake \"$FLAKE\";
system = \"$SYSTEM\";
pkgs = flake.inputs.nixpkgs.legacyPackages.\${system};
subject = pkgs.hello;
in flake.lib.\${system}.mkMacOSApp {
drv = subject;
name = \"SmokeHello\";
bundle = flake.inputs.nix-bundle-dir.bundlers.\${system}.$1 subject;
icon = builtins.toFile \"smoke.icns\" \"smoke-placeholder\";
infoPlist = $PLIST;
}"
}
echo "== bundlers exposed for $SYSTEM =="
BUNDLERS="$(nix eval --raw "$FLAKE#bundlers.$SYSTEM" --apply 'b: builtins.concatStringsSep " " (builtins.attrNames b)' 2>/dev/null)"
echo " $BUNDLERS"
# The .app bundlers are a 1:1 mirror of nix-bundle-dir's, so a bundler added
# upstream must appear here. qtCliApp (headless Qt, guiApp = false) is the one
# the tracked nix-bundle-dir revision adds; if it is missing, this repo is
# pinned to an older nix-bundle-dir than it claims.
check "the mirror exposes qtCliApp (i.e. nix-bundle-dir is current)" \
"printf '%s' '$BUNDLERS' | grep -qw qtCliApp" \
"got: $BUNDLERS"
echo
echo "== building the .app (qtApp) =="
build_app qtApp app-gui || exit 1
APP="$(readlink -f app-gui)/SmokeHello.app"
C="$APP/Contents"
# CFBundleExecutable is what launchd actually execs; read it rather than
# assuming, so a plist/layout mismatch shows up here instead of in Finder.
EXEC="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$C/Info.plist" 2>/dev/null)" || EXEC=""
# PlistBuddy prints its complaint on stdout and still exits 0 for some errors,
# so treat anything that is not a plain file name as "unreadable".
[ -n "$EXEC" ] && [ -e "$C/MacOS/$EXEC" ] || EXEC="<unreadable>"
echo
echo "== bundle contract =="
check "Contents/Info.plist exists" "[ -f '$C/Info.plist' ]"
check "Info.plist is well-formed (plutil -lint)" \
"plutil -lint '$C/Info.plist'" \
"$(plutil -lint "$C/Info.plist" 2>&1 | tail -1)"
check "Contents/MacOS/\$CFBundleExecutable exists and is executable" \
"[ -n '$EXEC' ] && [ -x '$C/MacOS/$EXEC' ]" \
"CFBundleExecutable=${EXEC:-<unset>}"
# mkMacOSApp renames the real program to <name>.bin and puts a tiny env
# wrapper in its place, so the Mach-O to inspect is the .bin.
MACHO="$C/MacOS/$EXEC.bin"
check "the program itself is a Mach-O executable" \
"file -b '$MACHO' | grep -q '^Mach-O'" \
"$(file -b "$MACHO" 2>&1)"
check "Contents/PkgInfo exists" "[ -f '$C/PkgInfo' ]"
check "Contents/Resources holds the icon" \
"ls '$C/Resources' | grep -q '\.icns$'"
echo
echo "== it runs =="
out="$("$C/MacOS/$EXEC" 2>&1)"
check "Contents/MacOS/$EXEC executes and prints its greeting" \
"printf '%s' \"\$out\" | grep -q 'Hello, world!'" \
"output: ${out:-<none>}"
echo
echo "== portability: nothing resolves back into /nix/store =="
# The actual promise of a distributable .app. otool -L on every Mach-O in the
# bundle, not just the main one: a single dylib that kept an absolute install
# name is enough to make the app fail to launch on a machine without Nix.
store_refs() { otool -L "$1" 2>/dev/null | tail -n +2 | grep '/nix/store' ; }
leaky=""
while IFS= read -r m; do
file -b "$m" 2>/dev/null | grep -q 'Mach-O' || continue
if store_refs "$m" | grep -q .; then leaky="$leaky $m"; fi
done < <(find "$C/MacOS" "$C/Frameworks" -type f 2>/dev/null)
check "no Mach-O in the .app links against /nix/store" \
"[ -z '$leaky' ]" \
"leaking:$leaky"
# An absolute store path in LC_RPATH is the same bug wearing a hat: otool -L
# then shows a clean @rpath/... name while the library is still found only on
# the build host.
check "no LC_RPATH entry points into /nix/store" \
"! otool -l '$MACHO' | grep -A2 LC_RPATH | grep -q /nix/store" \
"$(otool -l "$MACHO" | grep -A2 LC_RPATH | grep /nix/store | head -1)"
check "the wrapper script embeds no /nix/store path" \
"! grep -q /nix/store '$C/MacOS/$EXEC'"
echo
echo "== linking layout =="
# Dylibs live in Contents/Frameworks with Contents/lib symlinked to it, so
# @loader_path/../lib resolves from Contents/MacOS.
check "Contents/lib -> Frameworks symlink is present" \
"[ -L '$C/lib' ] && [ -d '$C/lib' ]"
check "every dylib dep is expressed relative to the binary" \
"! otool -L '$MACHO' | tail -n +2 | grep -vqE '@loader_path|@executable_path|@rpath|/usr/lib/|/System/'" \
"$(otool -L "$MACHO" | tail -n +2 | grep -vE '@loader_path|@executable_path|@rpath|/usr/lib/|/System/' | head -1)"
# ...and that each of those relative names actually lands on a file that is in
# the bundle. Naming alone proves nothing: with Contents/lib missing, dyld
# silently falls back to the shared cache's /usr/lib/libiconv.2.dylib and the
# app still prints its greeting on THIS mac while being broken for a user
# whose system copy differs or does not exist. Measured, not theoretical —
# deleting the lib -> Frameworks symlink leaves the run check green.
unresolved="$(unresolved_deps "$MACHO")"
check "every dylib dep resolves to a file inside the .app" \
"[ -z \"\$unresolved\" ]" \
"unresolved: $(printf '%s' "$unresolved" | tr '\n' ' ')"
# nix-bundle-dir now also gives every Mach-O a bare @loader_path rpath, so a
# plugin staged outside Contents/Frameworks still finds the companion dylibs
# sitting next to it. Absent => the pinned nix-bundle-dir predates that fix.
check "LC_RPATH includes a bare @loader_path (sibling-dylib lookup)" \
"otool -l '$MACHO' | awk '/LC_RPATH/{f=1} f && \$1==\"path\"{print \$2; f=0}' | grep -qx '@loader_path'" \
"rpaths: $(otool -l "$MACHO" | grep -A2 LC_RPATH | grep 'path ' | tr -s ' ' | paste -sd, -)"
echo
echo "== no Linux bundling artifacts crossed over =="
# nix-bundle-dir's launcher/companion machinery is guarded to the Linux arm.
# If that guard ever slips, a macOS .app grows a hidden companion Mach-O and a
# shell launcher in Contents/MacOS and stops passing codesign.
check "no hidden .<name>.elf companion in Contents/MacOS" \
"! ls -a '$C/MacOS' | grep -qE '^\.[^.]'" \
"$(ls -a "$C/MacOS" | grep -E '^\.[^.]' | tr '\n' ' ')"
check "no libprocself_fix.so anywhere in the .app" \
"! find '$APP' -name 'libprocself_fix.so' | grep -q ."
check "no ELF interpreter probing in the wrapper" \
"! grep -qE 'ld-linux|INTERP_NAME|__BUNDLE_REAL_EXE' '$C/MacOS/$EXEC'"
echo
echo "== guiApp is inert on macOS =="
# guiApp only gates the Linux launcher, so the headless bundler must produce a
# byte-identical .app payload. If this ever diverges, a Linux-only knob has
# started changing macOS output.
if build_app qtCliApp app-cli >/dev/null 2>&1; then
CLI="$(readlink -f app-cli)/SmokeHello.app"
check "qtCliApp and qtApp produce identical .app contents" \
"diff -r '$APP' '$CLI'" \
"$(diff -r "$APP" "$CLI" 2>&1 | head -3)"
else
bad "qtCliApp bundle builds" "the pinned nix-bundle-dir has no qtCliApp bundler"
fi
echo
echo "== $pass passed, $fail failed =="
[ "$fail" -eq 0 ]