diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1aeac12..49fada6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,11 +44,8 @@ jobs: cargo test --manifest-path rust/Cargo.toml --test legacy_parity cargo test --manifest-path rust/Cargo.toml --test security_regressions - - name: Build release binary - run: cargo build --manifest-path rust/Cargo.toml --release - - - name: Build native app bundle - run: ./scripts/build-native-app.sh + - name: Build universal release binaries + run: ./scripts/build-universal-release.sh - name: Build local source tarball for Homebrew smoke id: source_tarball @@ -99,9 +96,6 @@ jobs: ./rust/target/release/apw --version ./rust/target/release/apw status --json - - name: Build native app bundle - run: ./scripts/build-native-app.sh - - name: Homebrew smoke install from source tarball env: APW_SRC_TARBALL: ${{ steps.source_tarball.outputs.path }} @@ -150,6 +144,9 @@ jobs: tar -tzf "$archive" | grep -qx 'APW.app/Contents/Info.plist' tar -tzf "$archive" | grep -qx 'APW.app/Contents/MacOS/APW' + - name: Validate universal release binaries + run: ./scripts/verify-universal-binaries.sh + - name: Publish release assets uses: softprops/action-gh-release@v2 with: diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index b6de7cd..d36a54b 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -29,6 +29,14 @@ The resulting binary is: rust/target/release/apw ``` +To build release-equivalent macOS artifacts from source, including universal +`arm64` and `x86_64` slices for both the CLI and `APW.app`, run: + +```bash +./scripts/build-universal-release.sh +./scripts/verify-universal-binaries.sh +``` + ### Install manually ```bash @@ -54,6 +62,16 @@ apw APW.app/ ``` +Release archives are built as universal macOS artifacts. Verify the architecture +slices after extracting an archive with: + +```bash +lipo -archs ./apw +lipo -archs ./APW.app/Contents/MacOS/APW +``` + +Both commands must include `arm64` and `x86_64`. + Extract the archive and keep `apw` beside `APW.app` while installing the per-user app bundle: @@ -233,8 +251,8 @@ cargo fmt --manifest-path rust/Cargo.toml -- --check cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path rust/Cargo.toml --all-targets cargo test --manifest-path rust/Cargo.toml --test native_app_e2e -cargo build --manifest-path rust/Cargo.toml --release -./scripts/build-native-app.sh +./scripts/build-universal-release.sh +./scripts/verify-universal-binaries.sh ``` Optional parity and release helpers: diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index c6b4f14..a619fd3 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -60,8 +60,8 @@ cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path rust/Cargo.toml --all-targets cargo test --manifest-path rust/Cargo.toml --test legacy_parity cargo test --manifest-path rust/Cargo.toml --test native_app_e2e -cargo build --manifest-path rust/Cargo.toml --release -./scripts/build-native-app.sh +./scripts/build-universal-release.sh +./scripts/verify-universal-binaries.sh ``` ## Security-focused regression coverage diff --git a/scripts/build-apw-release.sh b/scripts/build-apw-release.sh index 5bd1495..9c242ec 100755 --- a/scripts/build-apw-release.sh +++ b/scripts/build-apw-release.sh @@ -3,11 +3,13 @@ set -euo pipefail print_help() { cat <<'EOF' -Usage: ./scripts/build-apw-release.sh [--install|--no-install] [--brew-smoke|--no-brew-smoke] +Usage: ./scripts/build-apw-release.sh [--universal|--no-universal] [--install|--no-install] [--brew-smoke|--no-brew-smoke] [--install-dir /usr/local/bin] [--skip-version] [--archive-smoke|--no-archive-smoke] Options: + --universal build universal arm64 + x86_64 release binaries (default) + --no-universal build only the host architecture --install install apw to --install-dir (defaults to /usr/local/bin) --no-install skip installation (default) --install-dir PATH destination directory for installation (default /usr/local/bin) @@ -31,6 +33,7 @@ CARGO_MANIFEST="$ROOT_DIR/rust/Cargo.toml" APP_BUNDLE_PATH="$ROOT_DIR/native-app/dist/APW.app" VERSION="$(awk -F ' = ' '/^version = / {gsub(/"/, "", $2); print $2; exit}' "$CARGO_MANIFEST")" ARCHIVE_PATH="$ROOT_DIR/dist/apw-macos-v${VERSION}.tar.gz" +UNIVERSAL_BUILD=1 INSTALL_BIN=0 INSTALL_DIR="/usr/local/bin" BREW_SMOKE=0 @@ -44,6 +47,12 @@ fi while [[ $# -gt 0 ]]; do case "$1" in + --universal) + UNIVERSAL_BUILD=1 + ;; + --no-universal) + UNIVERSAL_BUILD=0 + ;; --install) INSTALL_BIN=1 ;; @@ -97,15 +106,19 @@ if [ ! -f "$CARGO_MANIFEST" ]; then exit 1 fi -printf '\n[1/5] Building APW app bundle...\n' -cd "$ROOT_DIR" -"$ROOT_DIR/scripts/build-native-app.sh" - -printf '\n[2/5] Building release binary...\n' cd "$ROOT_DIR" -cargo build --manifest-path "$CARGO_MANIFEST" --release +if [ "$UNIVERSAL_BUILD" -eq 1 ]; then + printf '\n[release] Building universal release binaries...\n' + "$ROOT_DIR/scripts/build-universal-release.sh" +else + printf '\n[release] Building APW app bundle...\n' + "$ROOT_DIR/scripts/build-native-app.sh" + + printf '\n[release] Building release binary...\n' + cargo build --manifest-path "$CARGO_MANIFEST" --release +fi -printf '\n[3/5] Packaging release archive...\n' +printf '\n[release] Packaging release archive...\n' rm -rf "$ROOT_DIR/dist/apw" "$ROOT_DIR/dist/APW.app" mkdir -p "$ROOT_DIR/dist" cp "$BIN_PATH" "$ROOT_DIR/dist/apw" @@ -115,13 +128,18 @@ rm -rf "$ROOT_DIR/dist/apw" "$ROOT_DIR/dist/APW.app" echo "Created: $ARCHIVE_PATH" if [ "$SKIP_VERSION_CHECK" -ne 1 ]; then - printf '\n[4/5] Validating binary health...\n' + printf '\n[release] Validating binary health...\n' "$BIN_PATH" --version "$BIN_PATH" status --json fi +if [ "$UNIVERSAL_BUILD" -eq 1 ]; then + printf '\n[release] Validating universal binary slices...\n' + "$ROOT_DIR/scripts/verify-universal-binaries.sh" "$BIN_PATH" "$APP_BUNDLE_PATH/Contents/MacOS/APW" +fi + if [ "$ARCHIVE_SMOKE" -eq 1 ]; then - printf '\n[5/5] Validating release archive smoke...\n' + printf '\n[release] Validating release archive smoke...\n' smoke_dir="$(mktemp -d)" smoke_home="$(mktemp -d)" cleanup_smoke() { diff --git a/scripts/build-native-app.sh b/scripts/build-native-app.sh index d88ebff..a91bf6d 100755 --- a/scripts/build-native-app.sh +++ b/scripts/build-native-app.sh @@ -12,21 +12,51 @@ MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" PLIST_PATH="$CONTENTS_DIR/Info.plist" EXECUTABLE_PATH="$PACKAGE_DIR/.build/release/$EXECUTABLE_NAME" +UNIVERSAL=0 VERSION="$(awk -F ' = ' '$1 == "version" { gsub(/"/, "", $2); print $2; exit }' "$ROOT_DIR/rust/Cargo.toml")" +while [[ $# -gt 0 ]]; do + case "$1" in + --universal) + UNIVERSAL=1 + ;; + -h|--help) + cat <<'HELP' +Usage: ./scripts/build-native-app.sh [--universal] + +Build native-app/dist/APW.app. + +Options: + --universal Build APW.app as a universal arm64 + x86_64 bundle. +HELP + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac + shift +done + if [[ -z "$VERSION" ]]; then echo "Unable to determine APW version from rust/Cargo.toml" >&2 exit 1 fi -swift build --package-path "$PACKAGE_DIR" -c release +if [[ "$UNIVERSAL" -eq 1 ]]; then + swift build --package-path "$PACKAGE_DIR" -c release --arch arm64 --arch x86_64 + EXECUTABLE_PATH="$PACKAGE_DIR/.build/apple/Products/Release/$EXECUTABLE_NAME" +else + swift build --package-path "$PACKAGE_DIR" -c release +fi rm -rf "$APP_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" cp "$EXECUTABLE_PATH" "$MACOS_DIR/$EXECUTABLE_NAME" chmod 0755 "$MACOS_DIR/$EXECUTABLE_NAME" -RESOURCE_BUNDLE="$(find "$PACKAGE_DIR/.build" -path '*/release/*.bundle' -type d -name '*NativeAppLib*.bundle' | head -n 1 || true)" +RESOURCE_BUNDLE="$(find "$PACKAGE_DIR/.build" \( -path '*/release/*.bundle' -o -path '*/Release/*.bundle' \) -type d -name '*NativeAppLib*.bundle' | head -n 1 || true)" if [[ -n "$RESOURCE_BUNDLE" ]]; then cp -R "$RESOURCE_BUNDLE" "$RESOURCES_DIR/$(basename "$RESOURCE_BUNDLE")" fi diff --git a/scripts/build-universal-release.sh b/scripts/build-universal-release.sh new file mode 100755 index 0000000..522255f --- /dev/null +++ b/scripts/build-universal-release.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +print_help() { + cat <<'HELP' +Usage: ./scripts/build-universal-release.sh + +Build universal arm64 + x86_64 release binaries: + - rust/target/release/apw + - native-app/dist/APW.app/Contents/MacOS/APW + +Requires macOS, rustup, cargo, SwiftPM, lipo, and vendored OpenSSL build tools. +HELP +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + print_help + exit 0 +fi + +CHECK_BUILD_INPUTS=0 +if [[ "${1:-}" == "--check-build-inputs" ]]; then + CHECK_BUILD_INPUTS=1 + shift +fi + +if [[ $# -ne 0 ]]; then + echo "Unexpected arguments: $*" >&2 + print_help >&2 + exit 1 +fi + +ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CARGO_MANIFEST="$ROOT_DIR/rust/Cargo.toml" +CARGO_BIN="${CARGO_BIN:-cargo}" +RUSTUP_BIN="${RUSTUP_BIN:-rustup}" +LIPO_BIN="${LIPO_BIN:-lipo}" +TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) + +ensure_vendored_openssl_build_inputs() { + [[ "${OSTYPE:-}" == darwin* || "${APW_FORCE_OPENSSL_INPUT_CHECK:-}" == "1" ]] || return 0 + + local missing=() + for tool in cc make perl; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing+=("$tool") + fi + done + + if ((${#missing[@]} > 0)); then + echo "Missing required tool(s) for vendored OpenSSL build: ${missing[*]}" >&2 + echo "Install Xcode Command Line Tools and Perl on the macOS release runner." >&2 + echo "If intentionally using system OpenSSL, set OPENSSL_NO_VENDOR=1 and provide OPENSSL_DIR/PKG_CONFIG_PATH." >&2 + exit 1 + fi +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Universal macOS release builds must run on macOS." >&2 + exit 1 +fi + +ensure_vendored_openssl_build_inputs + +if [[ "$CHECK_BUILD_INPUTS" -eq 1 ]]; then + echo "Universal release build inputs are available." + exit 0 +fi + +if ! command -v "$RUSTUP_BIN" >/dev/null 2>&1 && [[ -x "$HOME/.cargo/bin/rustup" ]]; then + RUSTUP_BIN="$HOME/.cargo/bin/rustup" +fi + +RUSTUP_PATH="$(command -v "$RUSTUP_BIN" || true)" +if [[ "$CARGO_BIN" == "cargo" && -n "$RUSTUP_PATH" && -x "$(dirname "$RUSTUP_PATH")/cargo" ]]; then + CARGO_BIN="$(dirname "$RUSTUP_PATH")/cargo" +fi + +for tool in "$CARGO_BIN" "$RUSTUP_BIN" swift "$LIPO_BIN"; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "Required tool not found: $tool" >&2 + exit 1 + fi +done + +for target in "${TARGETS[@]}"; do + "$RUSTUP_BIN" target add "$target" + "$CARGO_BIN" build --manifest-path "$CARGO_MANIFEST" --release --target "$target" +done + +"$LIPO_BIN" -create \ + "$ROOT_DIR/rust/target/aarch64-apple-darwin/release/apw" \ + "$ROOT_DIR/rust/target/x86_64-apple-darwin/release/apw" \ + -output "$ROOT_DIR/rust/target/release/apw" +chmod 0755 "$ROOT_DIR/rust/target/release/apw" + +"$ROOT_DIR/scripts/build-native-app.sh" --universal +"$ROOT_DIR/scripts/verify-universal-binaries.sh" diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 46dacc7..564b845 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -42,5 +42,7 @@ done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-render-homebrew-formula.sh ./scripts/test-extended-validation-config.sh +./scripts/test-verify-universal-binaries.sh +./scripts/test-universal-release-config.sh echo "APW fast checks passed." diff --git a/scripts/test-universal-release-config.sh b/scripts/test-universal-release-config.sh new file mode 100755 index 0000000..9814014 --- /dev/null +++ b/scripts/test-universal-release-config.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if grep -Eq 'command -v brew|brew install|brew --prefix|brew list|command -v pkg-config|pkg-config --|AARCH64_APPLE_DARWIN_OPENSSL_DIR|X86_64_APPLE_DARWIN_OPENSSL_DIR' scripts/build-universal-release.sh; then + echo "Universal release build must not depend on Homebrew/pkg-config OpenSSL discovery." >&2 + exit 1 +fi + +tmp_bin="$(mktemp -d)" +trap 'rm -rf "$tmp_bin"' EXIT + +for tool in cc make perl uname; do + cat >"$tmp_bin/$tool" <<'EOF' +#!/bin/sh +if [ "$(basename "$0")" = "uname" ]; then + if [ "${1:-}" = "-s" ]; then + echo Darwin + elif [ "${1:-}" = "-m" ]; then + echo arm64 + else + echo Darwin + fi + exit 0 +fi +exit 0 +EOF + chmod +x "$tmp_bin/$tool" +done + +PATH="$tmp_bin:$PATH" APW_FORCE_OPENSSL_INPUT_CHECK=1 \ + bash scripts/build-universal-release.sh --check-build-inputs >/dev/null + +echo "Universal release configuration is deterministic." diff --git a/scripts/test-verify-universal-binaries.sh b/scripts/test-verify-universal-binaries.sh new file mode 100755 index 0000000..c3be496 --- /dev/null +++ b/scripts/test-verify-universal-binaries.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fake_lipo="$WORK_DIR/lipo" +fake_cli="$WORK_DIR/apw" +fake_app="$WORK_DIR/APW" +fake_missing_cli="$WORK_DIR/missing-apw" +fake_log="$WORK_DIR/lipo.log" + +cat >"$fake_lipo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$FAKE_LIPO_LOG" +if [[ "${1:-}" == "-archs" ]]; then + case "${2:-}" in + *missing*) + echo "arm64" + ;; + *) + echo "arm64 x86_64" + ;; + esac +else + echo "unexpected lipo arguments: $*" >&2 + exit 1 +fi +EOF +chmod +x "$fake_lipo" +printf '#!/usr/bin/env bash\n' >"$fake_cli" +printf '#!/usr/bin/env bash\n' >"$fake_app" +printf '#!/usr/bin/env bash\n' >"$fake_missing_cli" +chmod +x "$fake_cli" "$fake_app" "$fake_missing_cli" + +FAKE_LIPO_LOG="$fake_log" LIPO_BIN="$fake_lipo" \ + "$ROOT_DIR/scripts/verify-universal-binaries.sh" "$fake_cli" "$fake_app" >"$WORK_DIR/pass.out" +grep -q "apw: arm64 x86_64" "$WORK_DIR/pass.out" +grep -q "APW.app: arm64 x86_64" "$WORK_DIR/pass.out" + +if FAKE_LIPO_LOG="$fake_log" LIPO_BIN="$fake_lipo" \ + "$ROOT_DIR/scripts/verify-universal-binaries.sh" "$fake_missing_cli" "$fake_app" \ + >"$WORK_DIR/fail.out" 2>"$WORK_DIR/fail.err"; then + echo "verify-universal-binaries accepted a missing architecture." >&2 + exit 1 +fi +grep -q "expected arm64 and x86_64" "$WORK_DIR/fail.err" diff --git a/scripts/verify-universal-binaries.sh b/scripts/verify-universal-binaries.sh new file mode 100755 index 0000000..4f5275a --- /dev/null +++ b/scripts/verify-universal-binaries.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +print_help() { + cat <<'HELP' +Usage: ./scripts/verify-universal-binaries.sh [CLI_PATH] [APP_EXECUTABLE_PATH] + +Verify that APW release Mach-O binaries contain both arm64 and x86_64 slices. + +Defaults: + CLI_PATH: rust/target/release/apw + APP_EXECUTABLE_PATH: native-app/dist/APW.app/Contents/MacOS/APW +HELP +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + print_help + exit 0 +fi + +ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLI_PATH="${1:-$ROOT_DIR/rust/target/release/apw}" +APP_EXECUTABLE_PATH="${2:-$ROOT_DIR/native-app/dist/APW.app/Contents/MacOS/APW}" +LIPO_BIN="${LIPO_BIN:-lipo}" + +if ! command -v "$LIPO_BIN" >/dev/null 2>&1; then + echo "lipo not found. Universal binary verification must run on macOS with Xcode command line tools." >&2 + exit 1 +fi + +verify_binary() { + local label="$1" + local path="$2" + + if [[ ! -x "$path" ]]; then + echo "$label binary is missing or not executable: $path" >&2 + exit 1 + fi + + local archs + archs="$("$LIPO_BIN" -archs "$path")" + for required in arm64 x86_64; do + if [[ " $archs " != *" $required "* ]]; then + echo "$label binary is not universal; expected arm64 and x86_64, got: $archs" >&2 + exit 1 + fi + done + echo "$label: $archs" +} + +verify_binary "apw" "$CLI_PATH" +verify_binary "APW.app" "$APP_EXECUTABLE_PATH"