Files
tty7/.github/scripts/assert-macho.sh
T
l0ng-ai 47e25ef854 fix(ci): judge a Mach-O's signature by codesign's exit status (#696)
`codesign -dv` spells its signature line differently per posture:
`Signature=adhoc` for an ad-hoc or linker signature, `Signature size=8968`
for a Developer ID one with a timestamp. The check matched the literal
`Signature=`, which the second spelling does not contain.

While the script only pointed at the standalone tty7-server, which is
ad-hoc signed, that was invisible. #692 pointed it at the bundle's
tty7-app, tty7 and tty7-updater as well, and those are Developer ID
signed whenever the signing secrets are present. Pull requests do not see
the secrets, so every PR run took the ad-hoc branch and passed; the first
build that signed for real — the nightly — failed on all three binaries,
printing `CodeDirectory`, `Signature size=8968` and a Developer ID
`TeamIdentifier` as its proof they carried no signature. The binaries
were signed, notarized and stapled; only the assertion was wrong.

Exit status has no such split: 0 for anything signed, 1 with `code object
is not signed at all` for anything not, verified against all three
postures. The output is still captured so the failure message carries it.
2026-08-20 14:00:40 +08:00

95 lines
4.2 KiB
Bash
Executable File

#!/bin/bash
# Usage: assert-macho.sh <path-to-mach-o> <expected-arch>
# Fail unless the binary is a Mach-O executable for <expected-arch> that depends
# on nothing but the libraries every macOS already has, and carries a code
# signature.
#
# The macOS counterpart of assert-static.sh, and the same decision (D10) behind
# it: one `tty7-server` binary is pushed to an arbitrary remote Mac and has to
# run there with nothing installed alongside it. Static linking is not the
# instrument on macOS — Apple does not ship a static libSystem and linking one
# is unsupported — so the equivalent guarantee is "links only what the OS
# guarantees is present". A stray Homebrew dependency picked up from the runner
# would still compile, still pass a build-only job, and then fail on the first
# Mac that does not have /opt/homebrew — far from the change that caused it.
set -euo pipefail
BIN="$1"
WANT_ARCH="$2"
if [ ! -f "$BIN" ]; then
echo "::error::assert-macho.sh: $BIN does not exist"
exit 1
fi
echo "--- file ---"
file "$BIN"
echo "--- otool -L ---"
otool -L "$BIN"
echo "--- otool -l (build version) ---"
otool -l "$BIN" | grep -A 4 -E 'LC_BUILD_VERSION|LC_VERSION_MIN_MACOSX' || true
fail=0
# Each probe is captured into a variable and matched afterwards, never piped
# into `grep -q`. Under `pipefail` that pipeline is a coin toss: -q exits on the
# first match, the writer takes SIGPIPE, and the pipeline reports failure — so a
# binary that passes would be reported as failing, on the runs where grep
# happened to win the race.
FILE_SAYS=$(file "$BIN")
if [[ "$FILE_SAYS" != *"Mach-O 64-bit executable ${WANT_ARCH}"* ]]; then
echo "::error::$BIN is not a 64-bit Mach-O executable for ${WANT_ARCH}"
fail=1
fi
# Every dependency must live somewhere the OS owns. /usr/lib and
# /System/Library are the two prefixes shipped with macOS itself; anything else
# — /opt/homebrew, /usr/local, @rpath into a bundle we are not shipping — is a
# library the destination Mac has no reason to have.
#
# `tail -n +2` drops otool's first line, which is the binary's own path and
# would otherwise be judged as if it were a dependency.
STRAY=$(otool -L "$BIN" | tail -n +2 | awk '{print $1}' \
| grep -Ev '^(/usr/lib/|/System/Library/)' || true)
if [ -n "$STRAY" ]; then
echo "::error::$BIN links libraries that are not part of macOS:"
echo "$STRAY"
fail=1
fi
# arm64 refuses to execute an unsigned binary outright, so an unsigned build
# would not fail here but on the user's Mac, as "killed: 9" with no explanation.
#
# Asserted for both slices, not just arm64. The linker ad-hoc signs arm64 on its
# own and leaves x86_64 bare — which would be fine on an Intel Mac, but the
# x86_64 server is also what an Apple Silicon box gets when it asks through a
# Rosetta shell (`uname -sm` = "Darwin x86_64"), and that is not a machine to
# hand an unsigned binary to on a guess. The workflow signs it; this catches the
# day it stops.
# The verdict is `codesign -dv`'s exit status, not a word in its output. It
# spells the signature line differently per posture — `Signature=adhoc` for an
# ad-hoc or linker signature, `Signature size=8968` for a Developer ID one with
# a timestamp — so the `*"Signature="*` this used to match held only for ad-hoc.
# While the script pointed at the standalone tty7-server, which is ad-hoc
# signed, that was invisible; #692 pointed it at the bundle's binaries as well,
# and those are Developer ID signed whenever the signing secrets are present.
# Pull requests do not see the secrets, so every PR run took the ad-hoc branch
# and passed, and the first build that signed for real — the nightly — failed
# on all three binaries with `Signature size=` in the very output it printed as
# proof they were unsigned.
#
# Exit status has no such split: 0 for anything signed, 1 with `code object is
# not signed at all` for anything not. The output is still captured so the
# failure message can carry it.
if ! SIGNING=$(codesign -dv "$BIN" 2>&1); then
echo "::error::$BIN carries no code signature — arm64 macOS will refuse to run it"
echo "$SIGNING"
fail=1
fi
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "✅ $BIN is a self-contained ${WANT_ARCH} Mach-O ($(du -h "$BIN" | cut -f1))"