mirror of
https://github.com/logos-co/nix-bundle-macos-app.git
synced 2026-08-27 09:11:10 +00:00
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:
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 ]
|
||||
Reference in New Issue
Block a user