diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 4cee304c0..fbd9b637e 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -162,14 +162,13 @@ jobs: - name: Build plugin binaries env: TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_ID: ${{ secrets.APPLE_ID }} - NOTARY_PROFILE: TablePro NOTARIZE: "true" TARGET: ${{ steps.plugin.outputs.target }} VERSION: ${{ steps.plugin.outputs.version }} - run: | - ./scripts/build-plugin.sh "$TARGET" arm64 "$VERSION" - ./scripts/build-plugin.sh "$TARGET" x86_64 "$VERSION" + # One invocation, not one per architecture. The script has always taken "both", and + # each invocation regenerates both Xcode projects and wipes build/DerivedData, so calling + # it twice threw away everything the first architecture had compiled. + run: ./scripts/build-plugin.sh "$TARGET" both "$VERSION" # Assert the result rather than trusting the flag. The previous notarization step was # gated on an environment variable nothing defined, so it never ran and nothing noticed diff --git a/scripts/build-hiredis.sh b/scripts/build-hiredis.sh index 4696f5132..6b099a6b0 100755 --- a/scripts/build-hiredis.sh +++ b/scripts/build-hiredis.sh @@ -32,8 +32,7 @@ ARCH="${1:-both}" # deleted when the driver moved into a plugin bundle, so this script was installing them where # nothing read them and leaving the real ones untouched. HEADER_DIR="$REPO_ROOT/Plugins/RedisDriverPlugin/CRedis/include/hiredis" -BUILD_DIR="$(mktemp -d)" -NCPU=$(sysctl -n hw.ncpu) +make_build_dir echo "๐Ÿ”ง Building static hiredis $HIREDIS_VERSION + OpenSSL $OPENSSL_VERSION" echo " Deployment target: macOS $DEPLOY_TARGET" @@ -41,11 +40,6 @@ echo " Architecture: $ARCH" echo " Build dir: $BUILD_DIR" echo "" -cleanup() { - echo "๐Ÿงน Cleaning up build directory..." - rm -rf "$BUILD_DIR" -} -trap cleanup EXIT download_sources() { echo "๐Ÿ“ฅ Downloading source tarballs..." diff --git a/scripts/build-libmongoc.sh b/scripts/build-libmongoc.sh index 4f5106135..ae0e09aa4 100755 --- a/scripts/build-libmongoc.sh +++ b/scripts/build-libmongoc.sh @@ -32,8 +32,7 @@ MONGOC_SHA256="a93259840f461b28e198311e32144f5f8dc9fbd74348029f2793774d781bb7da" source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/macos.sh" ARCH="${1:-both}" -BUILD_DIR="$(mktemp -d)" -NCPU=$(sysctl -n hw.ncpu) +make_build_dir echo "๐Ÿ”ง Building static libmongoc $MONGOC_VERSION + OpenSSL $OPENSSL_VERSION" echo " Deployment target: macOS $DEPLOY_TARGET" @@ -41,11 +40,6 @@ echo " Architecture: $ARCH" echo " Build dir: $BUILD_DIR" echo "" -cleanup() { - echo "๐Ÿงน Cleaning up build directory..." - rm -rf "$BUILD_DIR" -} -trap cleanup EXIT download_sources() { echo "๐Ÿ“ฅ Downloading source tarballs..." diff --git a/scripts/build-libpq.sh b/scripts/build-libpq.sh index 61dbe5d22..bf99a1f27 100755 --- a/scripts/build-libpq.sh +++ b/scripts/build-libpq.sh @@ -33,8 +33,7 @@ ARCH="${1:-both}" # TablePro/Core/Database/CLibPQ/include, which no longer exists, so every rebuild recreated a dead # directory and never updated the headers the plugin actually compiles against. HEADER_DIR="$REPO_ROOT/Plugins/PostgreSQLDriverPlugin/CLibPQ/include" -BUILD_DIR="$(mktemp -d)" -NCPU=$(sysctl -n hw.ncpu) +make_build_dir echo "๐Ÿ”ง Building static libpq $PG_VERSION + OpenSSL $OPENSSL_VERSION" echo " Deployment target: macOS $DEPLOY_TARGET" @@ -42,11 +41,6 @@ echo " Architecture: $ARCH" echo " Build dir: $BUILD_DIR" echo "" -cleanup() { - echo "๐Ÿงน Cleaning up build directory..." - rm -rf "$BUILD_DIR" -} -trap cleanup EXIT download_sources() { echo "๐Ÿ“ฅ Downloading source tarballs..." diff --git a/scripts/build-libssh2.sh b/scripts/build-libssh2.sh index d7a202652..ee4b2ff87 100755 --- a/scripts/build-libssh2.sh +++ b/scripts/build-libssh2.sh @@ -27,8 +27,7 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/macos.sh" LIBSSH2_SHA256="d9ec76cbe34db98eec3539fe2c899d26b0c837cb3eb466a56b0f109cabf658f7" ARCH="${1:-both}" -BUILD_DIR="$(mktemp -d)" -NCPU=$(sysctl -n hw.ncpu) +make_build_dir echo "๐Ÿ”ง Building static libssh2 $LIBSSH2_VERSION + OpenSSL $OPENSSL_VERSION" echo " Deployment target: macOS $DEPLOY_TARGET" @@ -36,11 +35,6 @@ echo " Architecture: $ARCH" echo " Build dir: $BUILD_DIR" echo "" -cleanup() { - echo "๐Ÿงน Cleaning up build directory..." - rm -rf "$BUILD_DIR" -} -trap cleanup EXIT download_sources() { echo "๐Ÿ“ฅ Downloading source tarballs..." diff --git a/scripts/build-mariadb.sh b/scripts/build-mariadb.sh index 91933f892..c05116ba3 100755 --- a/scripts/build-mariadb.sh +++ b/scripts/build-mariadb.sh @@ -32,16 +32,13 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/macos.sh" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" LIBS_DIR="$PROJECT_DIR/Libs" -BUILD_DIR="$(mktemp -d)" -NCPU=$(sysctl -n hw.ncpu) +make_build_dir if [ -z "$OPENSSL_ROOT" ] || [ ! -d "$OPENSSL_ROOT" ]; then echo "ERROR: OpenSSL 3 not found. Install with 'brew install openssl@3' or set OPENSSL_ROOT." >&2 exit 1 fi -cleanup() { rm -rf "$BUILD_DIR"; } -trap cleanup EXIT echo "Building MariaDB Connector/C $MARIADB_VERSION for macOS (OpenSSL: $OPENSSL_ROOT)" diff --git a/scripts/build-plugin.sh b/scripts/build-plugin.sh index 7e10860fe..3dbda3278 100755 --- a/scripts/build-plugin.sh +++ b/scripts/build-plugin.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/notarize.sh" + # Build script for creating standalone plugin bundles # Usage: ./scripts/build-plugin.sh [arm64|x86_64|both] [version] # Example: ./scripts/build-plugin.sh OracleDriverPlugin arm64 1.0.0 @@ -19,10 +21,6 @@ BUILD_DIR="build/Plugins" SIGN_IDENTITY="${SIGN_IDENTITY:-}" TEAM_ID="${TEAM_ID:-}" NOTARIZE="${NOTARIZE:-false}" -APPLE_ID="${APPLE_ID:-}" -# The workflow's "Configure notarization" step stores its credentials under this -# name. A local build that keeps its own profile can override it. -NOTARY_PROFILE="${NOTARY_PROFILE:-TablePro}" if [ -z "$TEAM_ID" ]; then echo "ERROR: TEAM_ID is not set. Pass via env or set in your shell profile." >&2 @@ -178,59 +176,15 @@ create_zip() { # published before this ran was unnotarized, because the workflow gated the step on an # environment variable nothing ever set. # -# notarytool only accepts an archive, so the bundle is zipped to a throwaway path for the -# submission. The ticket then has to be stapled into the bundle itself, or every user needs -# a live round trip to Apple on first load and an offline Mac never gets one. Stapling -# rewrites the bundle, so the distribution zip and its SHA-256 must both be produced after -# it: the registry manifest pins that checksum and PluginInstaller rejects a mismatch. -notarize_and_staple() { - local plugin_path=$1 - +# Stapling rewrites the bundle, so the distribution zip and its SHA-256 must both be produced +# after it: the registry manifest pins that checksum and PluginInstaller rejects a mismatch. +notarize_plugin() { if [ "$NOTARIZE" != "true" ]; then echo "Skipping notarization (set NOTARIZE=true to enable)" return fi - - if [ -z "$APPLE_ID" ]; then - echo "ERROR: APPLE_ID is not set but NOTARIZE=true." >&2 - echo " Pass APPLE_ID=, and store credentials with" >&2 - echo " xcrun notarytool store-credentials \"$NOTARY_PROFILE\"." >&2 - exit 1 - fi - - local submission_zip - submission_zip="$(mktemp -d)/$(basename "$plugin_path" .tableplugin)-notarize.zip" - ditto -c -k --keepParent "$plugin_path" "$submission_zip" - - echo "Submitting $(basename "$plugin_path") for notarization..." - if ! xcrun notarytool submit "$submission_zip" \ - --apple-id "$APPLE_ID" \ - --team-id "$TEAM_ID" \ - --keychain-profile "$NOTARY_PROFILE" \ - --wait; then - echo "FATAL: Notarization failed for $plugin_path" >&2 - exit 1 - fi - - echo "Stapling the ticket into the bundle..." - if ! xcrun stapler staple "$plugin_path"; then - echo "FATAL: Stapling failed for $plugin_path" >&2 - exit 1 - fi - - if ! xcrun stapler validate "$plugin_path"; then - echo "FATAL: The stapled ticket did not validate for $plugin_path" >&2 - exit 1 - fi - - # spctl is what a user's Mac runs. A pass here is the only proof the bundle will load. - if ! spctl -a -vvv -t open --context context:primary-signature "$plugin_path" 2>&1 | grep -q "accepted"; then - echo "FATAL: Gatekeeper still rejects $plugin_path after notarization" >&2 - spctl -a -vvv -t open --context context:primary-signature "$plugin_path" || true - exit 1 - fi - - echo "Notarized, stapled and accepted by Gatekeeper" + # "open", not "exec": a plugin bundle is opened by the app, not launched. + notarize_and_staple "$1" open } # TablePro.xcodeproj is generated and not in git, so a fresh checkout has none. @@ -244,15 +198,15 @@ mkdir -p "$BUILD_DIR" case "$ARCH" in arm64|x86_64) plugin_path=$(build_plugin "$ARCH") - notarize_and_staple "$plugin_path" + notarize_plugin "$plugin_path" create_zip "$plugin_path" "$ARCH" ;; both) arm64_path=$(build_plugin "arm64") x86_path=$(build_plugin "x86_64") - notarize_and_staple "$arm64_path" - notarize_and_staple "$x86_path" + notarize_plugin "$arm64_path" + notarize_plugin "$x86_path" create_zip "$arm64_path" "arm64" create_zip "$x86_path" "x86_64" diff --git a/scripts/build-release.sh b/scripts/build-release.sh index fe4415c1f..b5db340af 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -3,6 +3,7 @@ set -euo pipefail # shellcheck source=lib/macos.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/macos.sh" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/notarize.sh" # Build script for creating architecture-specific releases # Usage: ./build-release.sh [arm64|x86_64|both] @@ -492,38 +493,9 @@ fi if [ "$NOTARIZE" = "true" ]; then echo "" echo "๐Ÿ“ฎ Notarizing..." - - # Requires: xcrun notarytool store-credentials "TablePro" --apple-id ... --team-id ... --password ... for app in "$BUILD_DIR"/TablePro-*.app; do [ -d "$app" ] || continue - name=$(basename "$app") - zip_path="$BUILD_DIR/${name%.app}.zip" - echo " Zipping $name..." - ditto -c -k --keepParent "$app" "$zip_path" - - echo " Submitting $name for notarization..." - # Assigned before the substitution, then overwritten on failure. Written the other way - # round, `submit_status=$?` reads the exit status of the assignment, which is always 0, - # so every failure took the success branch and the log fetch below was unreachable. - submit_status=0 - submit_output=$(xcrun notarytool submit "$zip_path" --keychain-profile "TablePro" --wait 2>&1) || submit_status=$? - echo "$submit_output" - - submission_id=$(echo "$submit_output" | grep "id:" | head -1 | awk '{print $2}') - - if [ $submit_status -eq 0 ] && echo "$submit_output" | grep -q "status: Accepted"; then - echo " Stapling $name..." - xcrun stapler staple "$app" - echo " โœ… $name notarized and stapled" - else - echo " โŒ Notarization failed for $name" - if [ -n "$submission_id" ]; then - echo " ๐Ÿ“‹ Fetching notarization log for $submission_id..." - xcrun notarytool log "$submission_id" --keychain-profile "TablePro" 2>&1 || true - fi - exit 1 - fi - rm -f "$zip_path" + notarize_and_staple "$app" exec done echo "โœ… Notarization complete" fi diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh index 8b244eb7a..a73abc0a2 100755 --- a/scripts/create-dmg.sh +++ b/scripts/create-dmg.sh @@ -256,17 +256,8 @@ echo "โœ… DMG signed" # Notarize the DMG (opt-in via NOTARIZE=true) if [ "$NOTARIZE" = "true" ]; then - echo "๐Ÿ“ฎ Notarizing DMG..." - if xcrun notarytool submit "$FINAL_DMG" --keychain-profile "TablePro" --wait; then - xcrun stapler staple "$FINAL_DMG" - # Stapling can report success and still leave no usable ticket, which is why - # build-plugin.sh validates after stapling. The DMG had no such check. - xcrun stapler validate "$FINAL_DMG" - echo "โœ… DMG notarized and stapled" - else - echo "โŒ DMG notarization failed" - exit 1 - fi + # "open", not "exec": a user opens a disk image, they do not launch it. + notarize_and_staple "$FINAL_DMG" open fi # Get final size diff --git a/scripts/lib/macos.sh b/scripts/lib/macos.sh index ac9baea11..b710ccc55 100644 --- a/scripts/lib/macos.sh +++ b/scripts/lib/macos.sh @@ -177,3 +177,17 @@ prepare_arch_libs() { return 1 fi } + +# Every library build wants one private scratch directory that goes away on exit. Five scripts +# each declared their own BUILD_DIR, an identical three-line cleanup() and the same trap; one of +# them had leaked its directory on every run until #2352. +# +# The trap body is single-quoted so it reads BUILD_DIR when it fires, not when it is installed. +# It replaces any EXIT trap already in place, so a script that needs to remove more than this +# directory installs its own instead (build-freetds.sh does, for its tarball cache). +make_build_dir() { + BUILD_DIR="$(mktemp -d)" + trap 'rm -rf "$BUILD_DIR"' EXIT +} + +NCPU="$(sysctl -n hw.ncpu)" diff --git a/scripts/lib/notarize.sh b/scripts/lib/notarize.sh new file mode 100644 index 000000000..76ebb9b65 --- /dev/null +++ b/scripts/lib/notarize.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Submit an artifact to Apple's notary service, staple the ticket, and prove it will open. +# Source this; it defines notarize_and_staple and sets nothing else. +# +# This existed three times at three rigor levels. build-plugin.sh stapled, validated and asked +# Gatekeeper; create-dmg.sh stapled and validated; build-release.sh only stapled, and it was the +# only one that fetched the notary log when a submission failed. So the app, the artifact users +# actually download, got the weakest checks and the DMG and plugins got the best diagnostics +# withheld. One implementation, at the strictest level all three reached between them. +# +# The two post-staple checks are not redundant. spctl asks Gatekeeper, which will fetch the +# ticket from Apple over the network, so an artifact that was notarized but never successfully +# stapled still passes: /Applications/Ghostty.app has no ticket and spctl accepts it. Only +# `stapler validate` proves the ticket is in the artifact, which is what an offline Mac needs. + +# The apple-signing action's "Configure notarization" step stores the Apple ID, team and +# app-specific password under this profile name, so --keychain-profile is the whole credential +# and passing --apple-id or --team-id alongside it is redundant. A local build with its own +# stored profile can override the name. +NOTARY_PROFILE="${NOTARY_PROFILE:-TablePro}" + +# Usage: notarize_and_staple [exec|open] +# +# The second argument is the Gatekeeper assessment type: "exec" for an application, "open" for a +# disk image or a plugin bundle, which a user opens rather than launches. +notarize_and_staple() { + local path="${1:?notarize_and_staple needs a path}" + local assessment="${2:-exec}" + local name + name="$(basename "$path")" + + [ -e "$path" ] || { echo "FATAL: nothing to notarize at $path" >&2; return 1; } + + # notarytool only accepts an archive, so a bundle is zipped to a throwaway path. The ticket + # is then stapled into the bundle itself, not the zip: without that every user needs a live + # round trip to Apple on first load and an offline Mac never gets one. Stapling rewrites the + # artifact, so any distribution zip and its checksum must be produced after this returns. + local submission="$path" scratch="" + if [ -d "$path" ]; then + scratch="$(mktemp -d)" + submission="$scratch/${name}.zip" + ditto -c -k --keepParent "$path" "$submission" + fi + + echo "Submitting $name for notarization..." + # Assigned before the substitution, then overwritten on failure. Written the other way round, + # `status=$?` reads the exit status of the assignment, which is always 0, so every failure + # took the success branch and the log fetch below was unreachable. + local submit_status=0 output="" + output="$(xcrun notarytool submit "$submission" --keychain-profile "$NOTARY_PROFILE" --wait 2>&1)" || submit_status=$? + echo "$output" + [ -z "$scratch" ] || rm -rf "$scratch" + + # Both checks, because notarytool has shipped versions that exit 0 on a rejected submission. + if [ "$submit_status" -ne 0 ] || ! echo "$output" | grep -q "status: Accepted"; then + echo "FATAL: notarization failed for $name" >&2 + local submission_id + submission_id="$(echo "$output" | grep "id:" | head -1 | awk '{print $2}')" + if [ -n "$submission_id" ]; then + echo "Notary log for $submission_id:" >&2 + xcrun notarytool log "$submission_id" --keychain-profile "$NOTARY_PROFILE" >&2 2>&1 || true + fi + return 1 + fi + + echo "Stapling the ticket into $name..." + xcrun stapler staple "$path" || { echo "FATAL: stapling failed for $name" >&2; return 1; } + + # Stapling can report success and still leave no usable ticket. + xcrun stapler validate "$path" || { echo "FATAL: the stapled ticket did not validate for $name" >&2; return 1; } + + # spctl is what a user's Mac runs. A pass here is the only proof the artifact will open. + local assessment_output + if ! assessment_output="$(spctl -a -vvv -t "$assessment" --context context:primary-signature "$path" 2>&1)" \ + || ! echo "$assessment_output" | grep -q "accepted"; then + echo "FATAL: Gatekeeper still rejects $name after notarization" >&2 + echo "$assessment_output" >&2 + return 1 + fi + + echo "$name notarized, stapled and accepted by Gatekeeper" +}