Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 130 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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/
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions apple/build_xcframework.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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 ->
Expand All @@ -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"
Loading