diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d10c71..9bc6f2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,7 +301,11 @@ jobs: path: dist/libdart_bridge-android-${{ matrix.abi }}-py${{ matrix.python_version }}.so if-no-files-found: error + # Unprivileged Apple build: no signing credentials are available here, so the + # artifact it produces is UNSIGNED and deliberately not publishable. Skipped on + # tags so a release run cannot possibly pick it up (see build-apple-signed). build-apple: + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} runs-on: macos-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -329,15 +333,124 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: dart_bridge-apple-xcframework + name: dart_bridge-apple-xcframework-unsigned path: dist/dart_bridge-apple.xcframework.zip if-no-files-found: error + # Release-only Apple build. Isolated from the general matrix so the provider + # certificate is never present in a PR or branch run: the `release-signing` + # environment holds the secrets and can carry branch/tag protection rules. + # + # Xcode records the SDK-origin signature of every XCFramework an app links + # against into the IPA's Signatures/ receipts. An unsigned dart_bridge makes + # that receipt read `signed = false`, which Apple's scan reports as + # ITMS-91065. Hence: no unsigned Apple artifact may ever reach a release. + build-apple-signed: + name: build-apple (provider-signed) + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + runs-on: macos-latest + environment: release-signing + env: + XCFRAMEWORK_EXPECTED_TEAM_ID: ${{ vars.XCFRAMEWORK_EXPECTED_TEAM_ID }} + # Turns every "credentials missing / signature not verifiable" case into a + # build failure instead of a silent unsigned artifact. + REQUIRE_XCFRAMEWORK_SIGNATURE: '1' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Download Python iOS dist (headers) + run: | + set -euo pipefail + VER=3.12 # abi3: any 3.12+ headers work + curl -fL -o pyios.tar.gz \ + "https://github.com/flet-dev/python-build/releases/download/v${VER}/python-ios-dart-${VER}.tar.gz" + mkdir -p pyios && tar -xzf pyios.tar.gz -C pyios + HEADER_DIR=$(find pyios -name Python.h -exec dirname {} \; | head -n1) + echo "PYTHON_HEADERS_DIR=$HEADER_DIR" >> "$GITHUB_ENV" + + - name: Import Apple Distribution certificate into a temporary keychain + env: + CERT_P12_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_BASE64 }} + CERT_P12_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_PASSWORD }} + run: | + set -euo pipefail + : "${CERT_P12_BASE64:?APPLE_DISTRIBUTION_CERT_P12_BASE64 is not set}" + : "${CERT_P12_PASSWORD:?APPLE_DISTRIBUTION_CERT_P12_PASSWORD is not set}" + + KEYCHAIN_PATH="$RUNNER_TEMP/xcframework-signing.keychain-db" + CERT_PATH="$RUNNER_TEMP/xcframework-signing.p12" + # Ephemeral: the keychain lives for this job only and is deleted in the + # always-run cleanup step, so the password never needs to leave it. + KEYCHAIN_PASSWORD=$(openssl rand -base64 24) + + printf '%s' "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # No -A: the private key is reachable only by the two Apple tools named + # below, not by any process that happens to run in this job. + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERT_P12_PASSWORD" \ + -f pkcs12 -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null + + # codesign resolves an identity through the search list even when + # --keychain is passed, so prepend ours to the user list. + security list-keychains -d user -s "$KEYCHAIN_PATH" \ + $(security list-keychains -d user | tr -d '"') + + # Derive EXACTLY ONE fingerprint. Selecting by display name is + # ambiguous when a keychain holds more than one matching certificate, + # and codesign then picks arbitrarily; a hard count check makes a + # multi-certificate .p12 a build failure instead of a coin flip. + IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH") + echo "$IDENTITIES" + FPRS=$(printf '%s\n' "$IDENTITIES" \ + | sed -n 's/^ *[0-9]*) \([0-9A-F]\{40\}\) .*/\1/p' | sort -u) + COUNT=$(printf '%s' "$FPRS" | grep -c . || true) + if [ "$COUNT" -ne 1 ]; then + echo "::error::expected exactly 1 codesigning identity in the imported keychain, found $COUNT" + exit 1 + fi + + # Fingerprint and keychain path are not secrets. + echo "XCFRAMEWORK_CODESIGN_IDENTITY=$FPRS" >> "$GITHUB_ENV" + echo "XCFRAMEWORK_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + # build_xcframework.sh signs the completed xcframework, verifies it, zips + # it, then extracts the zip into a fresh directory and verifies again. + - name: Build + sign xcframework + run: ./apple/build_xcframework.sh + + - name: Inspect artifact + run: | + ls -la dist/ + du -sh dist/dart_bridge-apple.xcframework.zip + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: dart_bridge-apple-xcframework-signed + path: dist/dart_bridge-apple.xcframework.zip + if-no-files-found: error + + - name: Remove temporary keychain and certificate + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/xcframework-signing.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP/xcframework-signing.p12" + # Tag-gated publish: collects every build job's artifacts and attaches # them to a GitHub Release named after the pushed tag. Skipped for # branch / PR runs. publish: - needs: [build-linux, build-windows, build-android, build-apple] + # build-apple-signed, never build-apple: the unsigned Apple job does not run + # on tags at all, and depending on the signed job here means a signing + # failure blocks the release rather than degrading it to an unsigned zip. + needs: [build-linux, build-windows, build-android, build-apple-signed] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: @@ -349,7 +462,22 @@ jobs: - name: Flatten artifacts into one dir run: | + set -euo pipefail mkdir -p dist + + # Belt and braces: build-apple is `if:`-skipped on tags, so an + # unsigned Apple artifact should not exist in this run. If one somehow + # does, fail rather than let the two same-named zips race for the same + # destination filename. + if [ -d artifacts/dart_bridge-apple-xcframework-unsigned ]; then + echo "::error::an UNSIGNED Apple artifact is present in a release run; refusing to publish" + exit 1 + fi + if [ ! -f artifacts/dart_bridge-apple-xcframework-signed/dart_bridge-apple.xcframework.zip ]; then + echo "::error::signed Apple artifact missing; refusing to publish" + exit 1 + fi + find artifacts -type f \( -name '*.so' -o -name '*.dll' -o -name '*.zip' \) -exec cp {} dist/ \; echo "=== Release payload ===" ls -lh dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e265538..00ab354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 1.7.0 + +### Apple: the published xcframework is provider-signed + +`dart_bridge.xcframework` is now code-signed with the Flet publishing team's +Apple Distribution identity, with a secure timestamp, before it is zipped. + +Xcode records the state of every `.xcframework` an app links against **as its +publisher shipped it**, and writes that into the IPA as +`Signatures/dart_bridge.xcframework-ios.signature`. An unsigned xcframework makes +that receipt read `signed = false` / `isSecureTimestamp = false`, which Apple's +App Store scan reports as `ITMS-91065: Missing signature`. Signing the app does +not fill this in — Xcode re-signs the *embedded copy* with the submitting team's +identity, and the SDK-origin receipt is a separate record. + +`apple/xcframework_signing.sh` holds the signing and verification helpers. +Signing happens after `xcodebuild -create-xcframework` and before the zip — the +last point at which the bundle is complete and unmutated — and the signature is +verified again after the zip is extracted into a fresh directory, so an archiving +bug shows up here rather than in a consumer's app. + +Release builds run in an isolated `release-signing` CI job that imports the +certificate into a temporary keychain, derives exactly one identity fingerprint, +and deletes the keychain unconditionally. The general build matrix (every push and +PR) has no access to the certificate and still produces unsigned artifacts for +testing; those are skipped on tags and can no longer reach a release. + +The outer seal is stamped with `-i dev.flet.dartbridge`, read off the inner +framework's own `CFBundleIdentifier`. An `.xcframework`'s root `Info.plist` is an +`XFWK` manifest with no `CFBundleIdentifier` of its own, so without this codesign +falls back to the bundle's file name and the seal reports a bare +`Identifier=dart_bridge`. Verification asserts the two agree, which also catches a +re-sign that dropped the flag. + +`dev.flet.dartbridge` is otherwise unchanged — it was already a stable, +publisher-owned identifier, which is what lets one signature cover every app that +embeds it. The compiled binaries are identical to 1.6.1; only the packaging of the +published artifact changed. + ## 1.6.1 ### Apple: preserve framework symlinks in the published zip diff --git a/CMakeLists.txt b/CMakeLists.txt index 1769fc9..95fc73a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,7 +17,7 @@ cmake_minimum_required(VERSION 3.15) -project(dart_bridge VERSION 1.6.1 LANGUAGES C) +project(dart_bridge VERSION 1.7.0 LANGUAGES C) if(NOT DEFINED DART_BRIDGE_PYTHON_INCLUDE_DIRS) find_package(Python3 REQUIRED COMPONENTS Development.Module) diff --git a/apple/build_xcframework.sh b/apple/build_xcframework.sh index 9eba328..e9e8235 100755 --- a/apple/build_xcframework.sh +++ b/apple/build_xcframework.sh @@ -24,6 +24,10 @@ # link time, now deferred to load time instead. # # Output: dist/dart_bridge.xcframework + dist/dart_bridge-apple.xcframework.zip +# +# Release builds additionally provider-sign the outer xcframework; see +# apple/xcframework_signing.sh for the environment it reads. Builds without those +# credentials still succeed and produce an unsigned (non-publishable) artifact. set -euo pipefail @@ -32,6 +36,15 @@ ROOT="$PWD" : "${PYTHON_HEADERS_DIR:?Set PYTHON_HEADERS_DIR to a dir containing Python.h}" +# shellcheck source=apple/xcframework_signing.sh +. "$ROOT/apple/xcframework_signing.sh" + +# Validate signing credentials before compiling: a release run that lost its +# certificate should fail in seconds, not after three slice builds. +preflight_rc=0 +xcf_signing_preflight || preflight_rc=$? +[ "$preflight_rc" -le 1 ] || exit 1 + BUILD="$ROOT/build/apple" DIST="$ROOT/dist" rm -rf "$BUILD" "$DIST/dart_bridge.xcframework" "$DIST/dart_bridge-apple.xcframework.zip" @@ -160,6 +173,18 @@ xcodebuild -create-xcframework \ -framework "$BUILD/macosx/${FW_NAME}.framework" \ -output "$DIST/${FW_NAME}.xcframework" +# --- Provider signature --------------------------------------------------- +# Last mutation of the bundle. `xcodebuild -create-xcframework` is the final +# step that writes into it, and the zip below only reads — so this is the one +# point where the artifact is both complete and still unsigned. Anything added +# between here and the zip would break the seal. +# +# dart_bridge already carries the stable provider identifier dev.flet.dartbridge +# (BUNDLE_ID above); it is never rewritten per consuming application, which is +# what lets one signature cover every app that embeds it. +echo "--- Signing xcframework ---" +xcf_sign_tree "$DIST/${FW_NAME}.xcframework" + echo "--- Zipping artifact ---" # -y stores symlinks AS symlinks. Without it zip follows them, and the macOS # slice's versioned bundle (`Versions/Current -> A`, `dart_bridge -> @@ -170,5 +195,16 @@ echo "--- Zipping artifact ---" # iOS uses a flat layout with no symlinks, so only macOS was affected. (cd "$DIST" && zip -qry "${FW_NAME}-apple.xcframework.zip" "${FW_NAME}.xcframework") +# --- Round-trip verification ---------------------------------------------- +# The published artifact is the ZIP, not the directory we just signed, so verify +# what consumers actually get. This is the check that would have caught the +# symlink-flattening bug fixed in 1.6.1 as a signature failure rather than as a +# codesign error in someone else's app. +echo "--- Verifying signature after archive round trip ---" +roundtrip_dir=$(mktemp -d) +trap 'rm -rf "$roundtrip_dir"' EXIT +unzip -q "$DIST/${FW_NAME}-apple.xcframework.zip" -d "$roundtrip_dir" +xcf_verify_tree "$roundtrip_dir/${FW_NAME}.xcframework" + echo "Done: $DIST/${FW_NAME}-apple.xcframework.zip" ls -lh "$DIST/${FW_NAME}-apple.xcframework.zip" diff --git a/apple/xcframework_signing.sh b/apple/xcframework_signing.sh new file mode 100644 index 0000000..5aa88c8 --- /dev/null +++ b/apple/xcframework_signing.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# +# Provider-signing helpers for Apple XCFrameworks. Source this file; it defines +# functions only and never signs anything on its own. +# +# WHY THIS EXISTS +# Xcode records the state of every XCFramework an app links against at the +# moment it is consumed, and writes the result into the IPA's top-level +# `Signatures/.xcframework-.signature` receipts. If the +# XCFramework we publish is unsigned, that receipt says `signed = false` / +# `isSecureTimestamp = false` no matter how the app itself is signed — +# app-signing the embedded inner framework does NOT retroactively supply an +# SDK-origin signature. Apple's App Store scan reports the gap as +# `ITMS-91065: Missing signature`. +# See https://developer.apple.com/documentation/Xcode/verifying-the-origin-of-your-xcframeworks +# +# ORDER MATTERS +# Signing seals the whole bundle by content hash. Every mutation — install +# names, Info.plists, privacy manifests, headers, pruning, stripping — must be +# finished BEFORE the outer `.xcframework` is signed, and nothing inside it may +# change afterwards. Sign last, verify, archive, then verify again after an +# archive round trip. +# +# INPUTS (environment) +# XCFRAMEWORK_CODESIGN_IDENTITY Certificate SHA-1 fingerprint of the Apple +# Distribution identity to sign with. A +# fingerprint, not a display name: display +# names are ambiguous when a keychain holds +# more than one matching certificate, and +# codesign then picks arbitrarily. +# XCFRAMEWORK_SIGNING_KEYCHAIN Path to the keychain holding that identity. +# Optional; the default search list is used +# when unset. +# XCFRAMEWORK_EXPECTED_TEAM_ID 10-character Team ID asserted on the +# resulting signature. Required when signing. +# XCFRAMEWORK_EXPECTED_AUTHORITY Substring every signature's authority chain +# must contain. Default "Apple Distribution". +# REQUIRE_XCFRAMEWORK_SIGNATURE Set to 1 for release builds: missing +# credentials, a missing timestamp, a wrong +# team, or an empty set of XCFrameworks all +# become hard failures instead of a skip. +# +# Local and PR builds supply none of these and produce unsigned artifacts, which +# is fine for testing and never publishable. + +xcf_log() { echo "xcframework-signing: $*"; } +xcf_warn() { echo "xcframework-signing: $*" >&2; } +xcf_err() { echo "xcframework-signing: ERROR: $*" >&2; } + +# True when a signing identity has been supplied. +xcf_signing_configured() { [ -n "${XCFRAMEWORK_CODESIGN_IDENTITY:-}" ]; } + +# True when this build must not publish an unsigned artifact. +xcf_signing_required() { [ "${REQUIRE_XCFRAMEWORK_SIGNATURE:-0}" = "1" ]; } + +# Validate the signing configuration once, up front, so a release build fails +# before spending minutes compiling rather than at the very last step. +# +# Returns 0 when signing is configured, 1 when it is not. Callers that must not +# proceed unsigned check xcf_signing_required themselves (or call +# xcf_sign_tree, which enforces it). +xcf_signing_preflight() { + if ! xcf_signing_configured; then + if xcf_signing_required; then + xcf_err "REQUIRE_XCFRAMEWORK_SIGNATURE=1 but XCFRAMEWORK_CODESIGN_IDENTITY is empty" + return 2 + fi + xcf_warn "no XCFRAMEWORK_CODESIGN_IDENTITY; artifacts will be UNSIGNED (not publishable)" + return 1 + fi + + if [ -z "${XCFRAMEWORK_EXPECTED_TEAM_ID:-}" ]; then + xcf_err "XCFRAMEWORK_CODESIGN_IDENTITY is set but XCFRAMEWORK_EXPECTED_TEAM_ID is empty;" \ + "refusing to sign without a team to verify against" + return 2 + fi + + if [ -n "${XCFRAMEWORK_SIGNING_KEYCHAIN:-}" ] && [ ! -f "$XCFRAMEWORK_SIGNING_KEYCHAIN" ]; then + xcf_err "keychain not found: $XCFRAMEWORK_SIGNING_KEYCHAIN" + return 2 + fi + + # Confirm the fingerprint actually resolves to a codesigning identity before + # the first `codesign` call, so a mis-imported certificate is reported as + # such instead of as "no identity found" from deep inside a build. + local identities + identities=$(security find-identity -v -p codesigning ${XCFRAMEWORK_SIGNING_KEYCHAIN:+"$XCFRAMEWORK_SIGNING_KEYCHAIN"} 2>&1) || { + xcf_err "security find-identity failed: $identities" + return 2 + } + if ! printf '%s\n' "$identities" | grep -qF "$XCFRAMEWORK_CODESIGN_IDENTITY"; then + xcf_err "identity $XCFRAMEWORK_CODESIGN_IDENTITY not present in" \ + "${XCFRAMEWORK_SIGNING_KEYCHAIN:-the default keychain search list}" + printf '%s\n' "$identities" >&2 + return 2 + fi + + xcf_log "signing with $XCFRAMEWORK_CODESIGN_IDENTITY (team ${XCFRAMEWORK_EXPECTED_TEAM_ID})" + return 0 +} + +# Enumerate every *.xcframework at or below the given roots, NUL-separated. +# +# -prune stops the walk at each match: only the OUTER bundle is a signing +# target, and re-signing something nested inside one would break the outer +# seal. NUL separation because module names come from arbitrary wheels. +xcf_find() { + local root + for root in "$@"; do + [ -e "$root" ] || continue + find "$root" -name '*.xcframework' -prune -print0 + done +} + +# The identifier to seal the OUTER bundle under. +# +# An .xcframework's root Info.plist is an XFWK manifest: it carries +# AvailableLibraries, CFBundlePackageType and XCFrameworkFormatVersion, and no +# CFBundleIdentifier at all. Left alone, codesign falls back to the bundle's file +# name, so the seal reports a bare `Identifier=dart_bridge` instead of a +# reverse-DNS one. Read the identifier off the xcframework's own inner framework +# instead: it is already stable and provider-owned in every artifact we publish, +# so the outer seal and the framework it wraps agree by construction and neither +# depends on the consuming application. +xcf_signing_identifier() { + local xcf=$1 + local name plist ident + name=$(basename "$xcf" .xcframework) + + local slice + for slice in "$xcf"/*/; do + [ -d "$slice$name.framework" ] || continue + # Flat (iOS) layout, then versioned (macOS). Versions/Current is a + # symlink to the real version directory, so skip it. + for plist in "$slice$name.framework/Info.plist" \ + "$slice$name.framework"/Versions/*/Resources/Info.plist; do + [ -f "$plist" ] || continue + case "$plist" in */Versions/Current/*) continue ;; esac + ident=$(plutil -extract CFBundleIdentifier raw -o - "$plist" 2>/dev/null) || continue + if [ -n "$ident" ]; then + printf '%s' "$ident" + return 0 + fi + done + done + return 1 +} + +# Sign one completed outer XCFramework. +xcf_sign_one() { + local xcf=$1 + [ -d "$xcf" ] || { xcf_err "not a directory: $xcf"; return 1; } + + local ident + if ! ident=$(xcf_signing_identifier "$xcf"); then + xcf_err "$xcf: no inner framework Info.plist with a CFBundleIdentifier;" \ + "cannot derive a signing identifier" + return 1 + fi + + local args=(--force --timestamp -i "$ident" --sign "$XCFRAMEWORK_CODESIGN_IDENTITY") + [ -n "${XCFRAMEWORK_SIGNING_KEYCHAIN:-}" ] && args+=(--keychain "$XCFRAMEWORK_SIGNING_KEYCHAIN") + + xcf_log "signing $xcf as $ident" + # Deliberately no --deep: it re-signs nested code with the outer options and + # is documented by Apple as inappropriate for producing a distributable + # signature. Deliberately no --timestamp=none: the receipt's + # isSecureTimestamp is exactly what we are here to make true. + codesign "${args[@]}" "$xcf" || { xcf_err "codesign failed for $xcf"; return 1; } +} + +# Verify one outer XCFramework carries a real provider signature. +xcf_verify_one() { + local xcf=$1 + local expect_team=${XCFRAMEWORK_EXPECTED_TEAM_ID:-} + local expect_authority=${XCFRAMEWORK_EXPECTED_AUTHORITY:-Apple Distribution} + + if [ ! -f "$xcf/_CodeSignature/CodeResources" ]; then + xcf_err "$xcf: no outer _CodeSignature/CodeResources — the XCFramework is unsigned" + return 1 + fi + + if ! codesign --verify --strict --verbose=4 "$xcf" 2>&1; then + xcf_err "$xcf: codesign --verify --strict failed" + return 1 + fi + + local info + if ! info=$(codesign -dvvv "$xcf" 2>&1); then + xcf_err "$xcf: codesign -dvvv failed: $info" + return 1 + fi + printf '%s\n' "$info" + + # An ad-hoc signature satisfies --verify but carries no identity at all, so + # it would sail past the checks below if they were the only ones. + if printf '%s\n' "$info" | grep -q '^Signature=adhoc'; then + xcf_err "$xcf: ad-hoc signature; a release artifact needs a real identity" + return 1 + fi + + # `Timestamp=` is the secure (Apple TSA) timestamp. A signature made without + # --timestamp reports `Signed Time=` instead, which is self-asserted and is + # what makes an IPA receipt report isSecureTimestamp = false. + if ! printf '%s\n' "$info" | grep -q '^Timestamp='; then + xcf_err "$xcf: no secure timestamp (signed without --timestamp?)" + return 1 + fi + + if ! printf '%s\n' "$info" | grep '^Authority=' | grep -qF "$expect_authority"; then + xcf_err "$xcf: no '$expect_authority' authority in the signature chain" + return 1 + fi + + if [ -n "$expect_team" ]; then + local actual_team + actual_team=$(printf '%s\n' "$info" | sed -n 's/^TeamIdentifier=//p' | head -1) + if [ "$actual_team" != "$expect_team" ]; then + xcf_err "$xcf: TeamIdentifier '$actual_team' != expected '$expect_team'" + return 1 + fi + fi + + # The outer seal must name the same provider-owned identifier as the inner + # framework. A mismatch means the bundle was re-signed by something that did + # not pass -i, and fell back to the file name. + local expect_ident actual_ident + if expect_ident=$(xcf_signing_identifier "$xcf"); then + actual_ident=$(printf '%s\n' "$info" | sed -n 's/^Identifier=//p' | head -1) + if [ "$actual_ident" != "$expect_ident" ]; then + xcf_err "$xcf: signing identifier '$actual_ident' != inner framework's '$expect_ident'" + return 1 + fi + fi + + xcf_log "verified $xcf" +} + +# Sign every XCFramework below the given roots. +# +# Skips (with a warning) when no identity is configured, unless +# REQUIRE_XCFRAMEWORK_SIGNATURE=1 — which is what keeps local and PR builds +# working while making a release that lost its credentials fail loudly. +xcf_sign_tree() { + if ! xcf_signing_configured; then + if xcf_signing_required; then + xcf_err "REQUIRE_XCFRAMEWORK_SIGNATURE=1 but no signing identity configured" + return 1 + fi + xcf_warn "skipping signing of: $* (no identity configured)" + return 0 + fi + + local list status=0 count=0 xcf + list=$(mktemp) + xcf_find "$@" > "$list" + while IFS= read -r -d '' xcf; do + if ! xcf_sign_one "$xcf"; then status=1; break; fi + if ! xcf_verify_one "$xcf"; then status=1; break; fi + count=$((count + 1)) + done < "$list" + rm -f "$list" + + [ "$status" -eq 0 ] || return 1 + if [ "$count" -eq 0 ]; then + xcf_err "no *.xcframework found under: $*" + return 1 + fi + xcf_log "signed and verified $count xcframework(s) under: $*" +} + +# Verify every XCFramework below the given roots. Fails when the tree contains +# none — an empty archive must never read as "everything passed". +# +# A no-op when signing is not configured, so unsigned local builds still run +# the same code path. +xcf_verify_tree() { + if ! xcf_signing_configured && ! xcf_signing_required; then + xcf_warn "skipping verification of: $* (no identity configured)" + return 0 + fi + + local list status=0 count=0 xcf + list=$(mktemp) + xcf_find "$@" > "$list" + while IFS= read -r -d '' xcf; do + if ! xcf_verify_one "$xcf"; then status=1; break; fi + count=$((count + 1)) + done < "$list" + rm -f "$list" + + [ "$status" -eq 0 ] || return 1 + if [ "$count" -eq 0 ]; then + xcf_err "no *.xcframework found under: $*" + return 1 + fi + xcf_log "verified $count xcframework(s) under: $*" +}