diff --git a/.github/workflows/build-wheels-version.yml b/.github/workflows/build-wheels-version.yml new file mode 100644 index 0000000..525622f --- /dev/null +++ b/.github/workflows/build-wheels-version.yml @@ -0,0 +1,411 @@ +name: Build and Publish wheels for Python version + +on: + workflow_call: + inputs: + python_version: + description: | + Python version: + required: true + type: string + archs: + description: | + Architectures (comma-separated): + required: false + type: string + default: "android,iOS" + packages: + description: | + Packages (comma-separated) or 'ALL' to select all available recipes: + required: true + type: string + prebuild_recipes: + description: | + Recipes (comma-separated; typically flet-lib*) to build FIRST and seed into + the matrix's dist/ for host-dep pip resolution (e.g. flet-libgdal -> gdal): + required: false + type: string + default: "" + mobile_test_pythons: + description: | + Python versions (comma-separated) whose recipe mobile tests should actually run. + Versions in `python_versions` that aren't in this list still build wheels but skip the + APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`: + required: false + type: string + default: "3.12.13" + python_build_run_id: + description: | + flet-dev/python-build Actions run-id whose artifacts to use as the + python-build support tree, instead of the v release tarball: + required: false + type: string + default: "" + secrets: + GEMFURY_TOKEN: + required: false + + workflow_dispatch: + inputs: + python_version: + description: | + Python version: + required: true + type: choice + options: + - 3.12.13 + - 3.13.13 + - 3.14.5 + default: 3.12.13 + archs: + description: | + Architectures (comma-separated): + required: false + default: "android,iOS" + packages: + description: | + Packages (comma-separated) or 'ALL' to select all available recipes: + required: false + default: "pydantic-core:,numpy:,pillow:" + prebuild_recipes: + description: | + Recipes (comma-separated; typically flet-lib*) to build FIRST and seed into + the matrix's dist/ for host-dep pip resolution (e.g. flet-libgdal -> gdal): + required: false + default: "" + mobile_test_pythons: + description: | + Python versions (comma-separated) whose recipe mobile tests should actually run. + Versions in `python_versions` that aren't in this list still build wheels but skip the + APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`: + required: false + default: "3.12.13" + python_build_run_id: + description: | + flet-dev/python-build Actions run-id whose artifacts to use as the + python-build support tree, instead of the v release tarball: + required: false + default: "" + +env: + FORGE_NDK_VERSION: r27d # used by forge for wheel cross-compile + FLUTTER_NDK_VERSION: "28.2.13676358" # used by flutter for apk build + MOBILE_FORGE_CACHE_DOWNLOADS_OFF: "1" # disable caching - guards against poisoned-cache failures + FLET_CLI_NO_RICH_OUTPUT: 1 + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - id: set-matrix + shell: bash + run: | + ARCHS="${{ inputs.archs }}" + PACKAGES="${{ inputs.packages }}" + py="${{ inputs.python_version }}" + py_short="${py%.*}" # 3.12.13 -> 3.12 + + matrix='{"include":[' + first=true + for arch in $(echo "$ARCHS" | tr ',' ' '); do + for pkg in $(echo "$PACKAGES" | tr ',' ' '); do + pkg_name="${pkg%%:*}" + if [[ "$arch" == "android" ]]; then + runner="ubuntu-latest" + platform="android" + rust_targets="aarch64-linux-android,arm-linux-androideabi,x86_64-linux-android,i686-linux-android" + else + runner="macos-26" + platform="ios" + rust_targets="aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" + fi + + # Read recipe meta (version + build_number + platforms) + recipe_version=""; recipe_build=""; declared="" + if [[ -f "recipes/$pkg_name/meta.yaml" ]]; then + IFS=$'\t' read -r recipe_version recipe_build declared \ + <<< "$(uv run --script .ci/read_meta.py "recipes/$pkg_name/meta.yaml")" + fi + + # Honor recipe's declared `platforms` on the matrix. + if [[ -n "$declared" && ! " $declared " == *" $platform "* ]]; then + echo "::notice::Skip ${platform}: ${pkg_name} — recipe declares platforms=[$declared]" + continue + fi + + # Job display name. + pkg_ver_override="${pkg#*:}" + display_version="${pkg_ver_override:-$recipe_version}" + display_build="${recipe_build:-0}" + job_name="${pkg_name} ${display_version} #${display_build} (${platform})" + + if [ "$first" = true ]; then first=false; else matrix+=','; fi + matrix+="{\"job_name\":\"$job_name\",\"artifact_name\":\"py${py_short}-${platform}-${pkg_name}\",\"runner\":\"$runner\",\"platform\":\"$platform\",\"forge_arch\":\"$arch\",\"forge_packages\":\"$pkg\",\"python_short\":\"$py_short\",\"rust_targets\":\"$rust_targets\"}" + done + done + matrix+=']}' + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + build: + needs: setup + name: ${{ matrix.job_name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup.outputs.matrix) }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Free disk space + if: runner.os == 'Linux' + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false # KEEP — we need android sdk + python tooling + android: false # KEEP — Android SDK is used by Build wheels (NDK) + Test + dotnet: true + haskell: true + large-packages: false # SKIP — sudo apt remove takes minutes; reaping above is enough + docker-images: true + swap-storage: true + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_targets }} + + - name: Install NDK ${{ env.FORGE_NDK_VERSION }} for forge on Android + if: matrix.platform == 'android' + shell: bash + run: .ci/install_ndk.sh "$FORGE_NDK_VERSION" + + - name: Build wheels + shell: bash + env: + FORGE_ARCH: ${{ matrix.forge_arch }} + FORGE_PACKAGES: ${{ matrix.forge_packages }} + PREBUILD_RECIPES: ${{ inputs.prebuild_recipes }} + PLATFORM: ${{ matrix.platform }} + PYTHON_BUILD_RUN_ID: ${{ inputs.python_build_run_id }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PIP_FIND_LINKS: ${{ github.workspace }}/dist + UV_PYTHON: ${{ inputs.python_version }} + run: | + set -euxo pipefail + + . .ci/common.sh + + if [[ "$PLATFORM" == "android" ]]; then + sudo apt-get update + # pkg-config is what meson's `py.dependency()` uses to resolve the Python C dep via + # the `.pc` files setup.sh relocates. Without it, meson falls back to sysconfig — which on + # Python 3.14 returns the bogus `/usr/local/...` paths and reports the dep as "not found". + sudo apt-get install -y sqlite3 pkg-config + fi + + # Sets up and downloads the matching mobile-forge support package for the requested platform. + source ./setup.sh "$UV_PYTHON" "$PLATFORM" + + # When prebuild_recipes is set, this step builds those recipes FIRST. Their wheels + # land in dist/ alongside the consumer wheels. PIP_FIND_LINKS points forge's + # host-dep pip resolution at dist/, so the consumer build below picks up the + # freshly-built libs over whatever pypi.flet.dev has published. + if [[ -n "${PREBUILD_RECIPES:-}" ]]; then + for lib in $(echo "$PREBUILD_RECIPES" | tr ',' ' '); do + forge "$FORGE_ARCH" "$lib" + done + fi + + IFS=' ' read -r -a packages <<< "$FORGE_PACKAGES" + for package in "${packages[@]}"; do + forge "$FORGE_ARCH" "$package" + done + + # Drop the support-tree dep wheels produced by make_dep_wheels.py + # iOS deps: bzip2, libffi, mpdecimal, openssl, xz + # Android deps: bzip2, libffi, openssl, sqlite, xz + rm -f dist/bzip2-* dist/libffi-* dist/mpdecimal-* dist/openssl-* dist/sqlite-* dist/xz-* + + - name: Detect test files + id: detect-tests + shell: bash + env: + FORGE_PACKAGES: ${{ matrix.forge_packages }} + PYTHON_SHORT: ${{ matrix.python_short }} + MOBILE_TEST_PYTHONS: ${{ inputs.mobile_test_pythons }} + run: | + set -euo pipefail + pkg_name="${FORGE_PACKAGES%%:*}" + pkg_version="${FORGE_PACKAGES#*:}" + [[ "$pkg_version" == "$FORGE_PACKAGES" ]] && pkg_version="" + + # Gate on the caller-provided mobile_test_pythons list: only Python + # versions in that list actually run the APK/iOS-sim test step. + if [[ "$MOBILE_TEST_PYTHONS" != "ALL" ]]; then + allowed="" + for v in $(echo "$MOBILE_TEST_PYTHONS" | tr ',' ' '); do + allowed="${allowed:+$allowed }${v%.*}" + done + if [[ ! " $allowed " == *" $PYTHON_SHORT "* ]]; then + echo "::notice::Skipping mobile test — py${PYTHON_SHORT} not in mobile_test_pythons=${MOBILE_TEST_PYTHONS}" + echo "has_tests=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + # Check for the presence of any test files at known locations. + if [[ -d "recipes/$pkg_name/tests" ]] \ + || [[ -d "recipes/$pkg_name/test" ]] \ + || compgen -G "recipes/$pkg_name/test_*.py" > /dev/null; then + echo "Found tests for $pkg_name" + echo "has_tests=true" >> "$GITHUB_OUTPUT" + echo "pkg_name=$pkg_name" >> "$GITHUB_OUTPUT" + echo "pkg_version=$pkg_version" >> "$GITHUB_OUTPUT" + else + echo "::notice::Skipping mobile test — no tests/ under recipes/$pkg_name/" + echo "has_tests=false" >> "$GITHUB_OUTPUT" + fi + + - name: Enable KVM + # GA'd April 2024 on standard Linux runners; needs a udev rule to grant the runner + # user rw on /dev/kvm. Android-only — macOS uses Hypervisor.Framework on its own. + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Install NDK ${{ env.FLUTTER_NDK_VERSION }} for Flutter Android build + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + shell: bash + run: .ci/install_ndk.sh "$FLUTTER_NDK_VERSION" + + - name: Stage tests + build recipe-tester APK + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + shell: bash + env: + PKG_NAME: ${{ steps.detect-tests.outputs.pkg_name }} + PKG_VERSION: ${{ steps.detect-tests.outputs.pkg_version }} + run: | + set -euxo pipefail + + # Copy just-built wheels into dist-test/ with build tags bumped to 9999. + # Reason: pip's resolver merges --find-links and the --extra-index-url (pypi.flet.dev) + # and picks the wheel with the highest build tag per PEP 427. The published wheel on + # pypi.flet.dev typically has build tag >= 1, while forge's freshly-built wheel for + # the same version may have a lower (or absent) build tag — so pip silently uses the OLD + # published wheel and the recipe fix being validated is bypassed. Bumping local copies to + # 9999 guarantees they win. Original dist/ is left untouched so the publish step still ships + # at the user-specified build_number. + mkdir -p "$GITHUB_WORKSPACE/dist-test" + for w in "$GITHUB_WORKSPACE"/dist/*.whl; do + [[ -e "$w" ]] || continue + base=$(basename "$w") + # name-version[-buildtag]-cpXY-cpXY-plat.whl + # → name-version-9999-cpXY-cpXY-plat.whl + new=$(printf '%s\n' "$base" \ + | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-cp[0-9]+)-/\1-9999-\3-/') + cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" + done + + ./tests/recipe-tester/stage_recipe.sh "$PKG_NAME" "$PKG_VERSION" + cd tests/recipe-tester + PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ + uvx --with flet-cli flet build apk --arch x86_64 -vv --yes + + - name: Test on Android emulator (API 28, x86_64) + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + uses: reactivecircus/android-emulator-runner@v2 + timeout-minutes: 20 + with: + # API 28 minimum, NOT 24. mobile-forge wheels target API 24, but Flet's Android app shell + # uses ImageDecoder.OnHeaderDecodedListener (added in API 28) — on a 24 AVD the recipe-tester + # APK crashes at launch with ClassNotFoundException before Python ever starts. + api-level: 28 + arch: x86_64 + target: default + disable-animations: true + script: .ci/run_android_test.sh + + - name: Stage tests + build recipe-tester iOS sim app + if: matrix.platform == 'ios' && steps.detect-tests.outputs.has_tests == 'true' + shell: bash + env: + PKG_NAME: ${{ steps.detect-tests.outputs.pkg_name }} + PKG_VERSION: ${{ steps.detect-tests.outputs.pkg_version }} + run: | + set -euxo pipefail + + # Same dist-test wheel-bump as the Android lane — pip's build-tag + # preference applies equally to iOS-tagged wheels resolving against + # pypi.flet.dev's published copies. + mkdir -p "$GITHUB_WORKSPACE/dist-test" + for w in "$GITHUB_WORKSPACE"/dist/*.whl; do + [[ -e "$w" ]] || continue + base=$(basename "$w") + new=$(printf '%s\n' "$base" \ + | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-(cp[0-9]+|abi[0-9]+))-/\1-9999-\3-/') + cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" + done + + ./tests/recipe-tester/stage_recipe.sh "$PKG_NAME" "$PKG_VERSION" + cd tests/recipe-tester + PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ + uvx --with flet-cli flet build ios-simulator -v --yes + + - name: Test on iOS Simulator + if: matrix.platform == 'ios' && steps.detect-tests.outputs.has_tests == 'true' + timeout-minutes: 25 + shell: bash + # iOS sim cold-boot can take 1-4 min on the macos-26 image. + run: .ci/run_ios_test.sh + + - name: Upload test artifacts + if: always() && steps.detect-tests.outputs.has_tests == 'true' + uses: actions/upload-artifact@v6 + with: + name: test-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + console.log + logcat-on-failure.txt + screen-on-failure.png + syslog-on-failure.txt + if-no-files-found: ignore + retention-days: 90 + + - name: Publish wheels + if: ${{ success() && hashFiles('dist/*.whl') != '' && github.event_name == 'push' && github.ref == 'refs/heads/main' && inputs.python_build_run_id == '' }} + shell: bash + env: + GEMFURY_TOKEN: ${{ secrets.GEMFURY_TOKEN }} + run: | + set -euxo pipefail + . .ci/common.sh + publish_to_pypi dist/*.whl + + - name: Upload logs on success + if: ${{ success() && hashFiles('logs/*.log') != '' }} + uses: actions/upload-artifact@v6 + with: + name: logs-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} + path: logs/*.log + + - name: Upload errors on failure + if: ${{ failure() && hashFiles('errors/*.log') != '' }} + uses: actions/upload-artifact@v6 + with: + name: errors-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} + path: errors/*.log diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index 7865d8b..96982a5 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -6,51 +6,93 @@ on: workflow_dispatch: inputs: archs: - description: "Architectures (comma-separated, e.g. android,iOS)" + description: | + Architectures (comma-separated): required: false default: "android,iOS" packages: - description: "Packages (comma-separated, e.g. pillow:11.1.0,pydantic-core:2.33.2) — or 'ALL' to build/test every recipe" + description: | + Packages (comma-separated) or 'ALL' to select all available recipes: required: false - default: "pydantic-core:2.33.2" + default: "pydantic-core:,numpy:,pillow:" prebuild_recipes: description: | - Comma-separated list of recipes (typically flet-lib*) to - build FIRST and seed into the matrix's dist/ for host-dep pip resolution - (e.g. flet-libgdal -> gdal). + Recipes (comma-separated; typically flet-lib*) to build FIRST and seed into + the matrix's dist/ for host-dep pip resolution (e.g. flet-libgdal -> gdal): required: false default: "" + python_versions: + description: | + Python versions to fan the matrix across (comma-separated): + required: false + default: "3.12.13,3.13.13,3.14.5" + mobile_test_pythons: + description: | + Python versions (comma-separated) whose recipe mobile tests should actually run. + Versions in `python_versions` that aren't in this list still build wheels but skip the + APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`: + required: false + default: "3.12.13" python_build_run_id: description: | flet-dev/python-build Actions run-id whose artifacts to use as the - python-build support tree, instead of the v release tarball. + python-build support tree, instead of the v release tarball: required: false default: "" + # Reusable-workflow entry point for cross-repo callers. + workflow_call: + inputs: + archs: + type: string + required: false + default: "android,iOS" + packages: + type: string + required: false + default: "pydantic-core:,numpy:,pillow:" + prebuild_recipes: + type: string + required: false + default: "" + python_versions: + type: string + required: false + default: "3.12.13,3.13.13,3.14.5" + mobile_test_pythons: + type: string + required: false + default: "3.12.13" + python_build_run_id: + type: string + required: false + default: "" + secrets: + GEMFURY_TOKEN: + required: false + # Cancel in-flight runs when a newer event arrives for the same logical branch. concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true env: - UV_PYTHON: "3.12.13" - MOBILE_FORGE_CACHE_DOWNLOADS_OFF: "1" - FORGE_NDK_VERSION: r27d # used by forge for wheel cross-compile. - FLUTTER_NDK_VERSION: "28.2.13676358" # used by flutter for apk build. - FLET_CLI_NO_RICH_OUTPUT: 1 + DEFAULT_PYTHONS: "3.12.13,3.13.13,3.14.5" + # Used as the fallback recipe set when `packages` input is empty + # (workflow_dispatch/workflow_call) or when no `recipes/**` paths + # changed in the triggering event (push/pull_request). + SMOKE_TEST_PACKAGES: "pydantic-core:,numpy:,pillow:" jobs: - setup: + detect: runs-on: ubuntu-latest outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} + pythons_json: ${{ steps.detect-pythons.outputs.pythons_json }} + packages: ${{ steps.detect-packages.outputs.packages }} steps: - name: Checkout uses: actions/checkout@v5 - - name: Setup uv - uses: astral-sh/setup-uv@v7 - - name: Get changed recipes id: changed-recipes uses: tj-actions/changed-files@v47 @@ -59,6 +101,25 @@ jobs: dir_names: true dir_names_max_depth: 2 + - id: detect-pythons + # Decide which Python versions this run fans out across. + shell: bash + env: + INPUT_PYTHONS: ${{ inputs.python_versions }} + run: | + pythons="${INPUT_PYTHONS:-$DEFAULT_PYTHONS}" + + # Hand-built JSON array — no jq dep needed. + json="[" + first=true + for py in $(echo "$pythons" | tr ',' ' '); do + if [ "$first" = true ]; then first=false; else json+=","; fi + json+="\"$py\"" + done + json+="]" + echo "Detected pythons: $pythons -> $json" + echo "pythons_json=$json" >> "$GITHUB_OUTPUT" + - id: detect-packages shell: bash env: @@ -66,8 +127,7 @@ jobs: INPUT_PACKAGES: ${{ inputs.packages }} CHANGED_DIRS: ${{ steps.changed-recipes.outputs.all_changed_files }} run: | - SMOKE_TEST="pydantic-core:2.33.2" - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" || "$GITHUB_EVENT_NAME" == "workflow_call" ]]; then # The literal value "ALL" expands to every recipe with a meta.yaml under recipes/. if [[ "$INPUT_PACKAGES" == "ALL" ]]; then pkgs="" @@ -77,7 +137,7 @@ jobs: pkgs="${pkgs:+$pkgs,}${pkg}:" done else - pkgs="${INPUT_PACKAGES:-$SMOKE_TEST}" + pkgs="${INPUT_PACKAGES:-$SMOKE_TEST_PACKAGES}" fi else pkgs="" @@ -85,326 +145,27 @@ jobs: [[ -f "$dir/meta.yaml" ]] || continue # skip deleted recipes pkgs="${pkgs:+$pkgs,}${dir#recipes/}:" done - pkgs="${pkgs:-$SMOKE_TEST}" + pkgs="${pkgs:-$SMOKE_TEST_PACKAGES}" fi echo "Detected packages: $pkgs" echo "packages=$pkgs" >> "$GITHUB_OUTPUT" - - id: set-matrix - shell: bash - run: | - ARCHS="${{ inputs.archs || 'android,iOS' }}" - PACKAGES="${{ steps.detect-packages.outputs.packages }}" - - matrix='{"include":[' - first=true - for arch in $(echo "$ARCHS" | tr ',' ' '); do - for pkg in $(echo "$PACKAGES" | tr ',' ' '); do - pkg_name="${pkg%%:*}" - if [[ "$arch" == "android" ]]; then - runner="ubuntu-latest" - platform="android" - rust_targets="aarch64-linux-android,arm-linux-androideabi,x86_64-linux-android,i686-linux-android" - else - runner="macos-26" - platform="ios" - rust_targets="aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" - fi - # Read recipe meta (version + build_number + platforms) - recipe_version=""; recipe_build=""; declared="" - if [[ -f "recipes/$pkg_name/meta.yaml" ]]; then - IFS=$'\t' read -r recipe_version recipe_build declared \ - <<< "$(uv run --script .ci/read_meta.py "recipes/$pkg_name/meta.yaml")" - fi - - # Honor recipe's `package.platforms` on the matrix. - if [[ -n "$declared" && ! " $declared " == *" $platform "* ]]; then - echo "::notice::Skip ${platform}: ${pkg_name} — recipe declares platforms=[$declared]" - continue - fi - - # Compose the job display name. The build number comes from - # the recipe's meta.yaml (read above): #59 dropped the - # build_number workflow input, so meta.yaml is the source of - # truth. - pkg_ver_override="${pkg#*:}" - display_version="${pkg_ver_override:-$recipe_version}" - display_build="${recipe_build:-0}" - job_name="${platform}: ${pkg_name} ${display_version} #${display_build}" - - if [ "$first" = true ]; then first=false; else matrix+=','; fi - matrix+="{\"job_name\":\"$job_name\",\"artifact_name\":\"${platform}-${pkg_name}\",\"runner\":\"$runner\",\"platform\":\"$platform\",\"forge_arch\":\"$arch\",\"forge_packages\":\"$pkg\",\"rust_targets\":\"$rust_targets\"}" - done - done - matrix+=']}' - echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - + # One child workflow run per Python version. build: - needs: setup - name: ${{ matrix.job_name }} - runs-on: ${{ matrix.runner }} + name: Python ${{ matrix.python_version }} + needs: detect strategy: fail-fast: false - matrix: ${{ fromJson(needs.setup.outputs.matrix) }} - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Free disk space (Ubuntu runners only) - if: runner.os == 'Linux' - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false # KEEP — we need android sdk + python tooling - android: false # KEEP — Android SDK is used by Build wheels (NDK) + Test - dotnet: true - haskell: true - large-packages: false # SKIP — sudo apt remove takes minutes; reaping above is enough - docker-images: true - swap-storage: true - - - name: Setup uv - uses: astral-sh/setup-uv@v7 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.rust_targets }} - - - name: Install NDK ${{ env.FORGE_NDK_VERSION }} for forge on Android - # forge cross-compiles Android wheels with this NDK. The install - # script writes NDK_HOME to $GITHUB_ENV so the Build wheels step - # (and forge subprocesses) pick it up. - if: matrix.platform == 'android' - shell: bash - run: .ci/install_ndk.sh "$FORGE_NDK_VERSION" - - - name: Build wheels - shell: bash - env: - FORGE_ARCH: ${{ matrix.forge_arch }} - FORGE_PACKAGES: ${{ matrix.forge_packages }} - PREBUILD_RECIPES: ${{ inputs.prebuild_recipes }} - PLATFORM: ${{ matrix.platform }} - PYTHON_BUILD_RUN_ID: ${{ inputs.python_build_run_id }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PIP_FIND_LINKS: ${{ github.workspace }}/dist - run: | - set -euxo pipefail - - . .ci/common.sh - - if [[ "$PLATFORM" == "android" ]]; then - sudo apt-get update - sudo apt-get install -y sqlite3 - fi - - # setup.sh downloads the matching mobile-forge support package for the - # requested platform and sets MOBILE_FORGE_*_SUPPORT_PATH. When the - # PYTHON_BUILD_RUN_ID env (from inputs above) is set, setup.sh - # fetches from that python-build Actions run's artifacts instead of - # the canonical v release. - source ./setup.sh "$UV_PYTHON" "$PLATFORM" - - # When prebuild_recipes is set, this step builds those recipes FIRST. Their wheels - # land in dist/ alongside the consumer wheels. PIP_FIND_LINKS points forge's - # host-dep pip resolution at dist/, so the consumer build below picks up the - # freshly-built libs over whatever pypi.flet.dev has published. - if [[ -n "${PREBUILD_RECIPES:-}" ]]; then - for lib in $(echo "$PREBUILD_RECIPES" | tr ',' ' '); do - forge "$FORGE_ARCH" "$lib" - done - fi - - IFS=' ' read -r -a packages <<< "$FORGE_PACKAGES" - for package in "${packages[@]}"; do - forge "$FORGE_ARCH" "$package" - done - - # Drop the support-tree dep wheels produced by make_dep_wheels.py - # iOS deps: bzip2, libffi, mpdecimal, openssl, xz - # Android deps: bzip2, libffi, openssl, sqlite, xz - rm -f dist/bzip2-* dist/libffi-* dist/mpdecimal-* dist/openssl-* dist/sqlite-* dist/xz-* - - # --- Mobile test lane --------------------------------------------------- - - - name: Detect test files for this recipe - id: detect-tests - shell: bash - env: - FORGE_PACKAGES: ${{ matrix.forge_packages }} - run: | - set -euo pipefail - pkg_name="${FORGE_PACKAGES%%:*}" - pkg_version="${FORGE_PACKAGES#*:}" - [[ "$pkg_version" == "$FORGE_PACKAGES" ]] && pkg_version="" - - # Check for the presence of any test files at known locations. - if [[ -d "recipes/$pkg_name/tests" ]] \ - || [[ -d "recipes/$pkg_name/test" ]] \ - || compgen -G "recipes/$pkg_name/test_*.py" > /dev/null; then - echo "Found tests for $pkg_name" - echo "has_tests=true" >> "$GITHUB_OUTPUT" - echo "pkg_name=$pkg_name" >> "$GITHUB_OUTPUT" - echo "pkg_version=$pkg_version" >> "$GITHUB_OUTPUT" - else - echo "::notice::Skipping mobile test — no tests/, test/ or test_*.py under recipes/$pkg_name/" - echo "has_tests=false" >> "$GITHUB_OUTPUT" - fi - - - name: Enable KVM - # GA'd April 2024 on standard Linux runners; needs a udev rule to grant the runner - # user rw on /dev/kvm. Android-only — macOS uses Hypervisor.Framework on its own. - if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Install NDK ${{ env.FLUTTER_NDK_VERSION }} for Flutter Android build - # `flet build apk` shells out to Flutter, whose generated Gradle - # uses `ndkVersion = flutter.ndkVersion`. Without this NDK - # pre-installed at $ANDROID_HOME/ndk//, Gradle triggers - # an auto-install mid-build that flakes intermittently - # (InstallFailedException / ZipException). Pre-installing via - # the shared install_ndk.sh removes that race. - if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' - shell: bash - run: .ci/install_ndk.sh "$FLUTTER_NDK_VERSION" - - - name: Stage tests + build recipe-tester APK - if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' - shell: bash - env: - PKG_NAME: ${{ steps.detect-tests.outputs.pkg_name }} - PKG_VERSION: ${{ steps.detect-tests.outputs.pkg_version }} - run: | - set -euxo pipefail - - # Copy just-built wheels into dist-test/ with build tags bumped to - # 9999. Reason: pip's resolver merges --find-links and the - # --extra-index-url (pypi.flet.dev) and picks the wheel with the - # highest build tag per PEP 427. The published wheel on - # pypi.flet.dev typically has build tag >= 1, while forge's - # freshly-built wheel for the same version may have a lower (or - # absent) build tag — so pip silently uses the OLD published wheel - # and the recipe fix being validated is bypassed. Bumping local - # copies to 9999 guarantees they win. Original dist/ is left - # untouched so the publish step still ships at the user-specified - # build_number. - mkdir -p "$GITHUB_WORKSPACE/dist-test" - for w in "$GITHUB_WORKSPACE"/dist/*.whl; do - [[ -e "$w" ]] || continue - base=$(basename "$w") - # name-version[-buildtag]-pytag-abitag-plat.whl - # → name-version-9999-pytag-abitag-plat.whl - # abitag covers both cp312-cp312 (Python-specific) and - # cp37-abi3 (stable ABI, used e.g. by cryptography). - new=$(printf '%s\n' "$base" \ - | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-(cp[0-9]+|abi[0-9]+))-/\1-9999-\3-/') - cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" - done - - ./tests/recipe-tester/stage_recipe.sh "$PKG_NAME" "$PKG_VERSION" - cd tests/recipe-tester - PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ - uvx --with flet-cli flet build apk --arch x86_64 -vv --yes - - - name: Test on Android emulator (API 28, x86_64) - if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' - uses: reactivecircus/android-emulator-runner@v2 - timeout-minutes: 20 - with: - # API 28 minimum, NOT 24. mobile-forge wheels target API 24, but - # Flet's Android app shell uses ImageDecoder.OnHeaderDecodedListener - # (added in API 28) — on a 24 AVD the recipe-tester APK crashes at - # launch with ClassNotFoundException before Python ever starts. - api-level: 28 - arch: x86_64 - target: default - disable-animations: true - # The reactivecircus action invokes EACH LINE of `script:` through - # `sh -c` separately — multi-line constructs (functions, traps, if - # blocks) don't survive. Logic lives in .ci/run_android_test.sh - # instead, which has its own bash shebang. - script: .ci/run_android_test.sh - - # --- iOS lane ---------------------------------------------------------- - - - name: Stage tests + build recipe-tester iOS sim app - if: matrix.platform == 'ios' && steps.detect-tests.outputs.has_tests == 'true' - shell: bash - env: - PKG_NAME: ${{ steps.detect-tests.outputs.pkg_name }} - PKG_VERSION: ${{ steps.detect-tests.outputs.pkg_version }} - run: | - set -euxo pipefail - - # Same dist-test wheel-bump as the Android lane — pip's build-tag - # preference applies equally to iOS-tagged wheels resolving against - # pypi.flet.dev's published copies. - mkdir -p "$GITHUB_WORKSPACE/dist-test" - for w in "$GITHUB_WORKSPACE"/dist/*.whl; do - [[ -e "$w" ]] || continue - base=$(basename "$w") - new=$(printf '%s\n' "$base" \ - | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-(cp[0-9]+|abi[0-9]+))-/\1-9999-\3-/') - cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" - done - - ./tests/recipe-tester/stage_recipe.sh "$PKG_NAME" "$PKG_VERSION" - cd tests/recipe-tester - PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ - uvx --with flet-cli flet build ios-simulator -v --yes - - - name: Test on iOS Simulator - if: matrix.platform == 'ios' && steps.detect-tests.outputs.has_tests == 'true' - timeout-minutes: 25 - shell: bash - # iOS sim cold-boot can take 1-4 min on the macos-26 image. - run: .ci/run_ios_test.sh - - # --- /iOS lane --------------------------------------------------------- - - - name: Upload test artifacts - if: always() && steps.detect-tests.outputs.has_tests == 'true' - uses: actions/upload-artifact@v6 - with: - name: test-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} - path: | - console.log - logcat-on-failure.txt - screen-on-failure.png - syslog-on-failure.txt - if-no-files-found: ignore - retention-days: 90 - - # --- /Mobile test lane ------------------------------------------------ - - - name: Publish wheels - # `success() &&` so a test failure blocks publish — without it, a - # passing build with failing tests would still ship the wheel. - if: ${{ success() && hashFiles('dist/*.whl') != '' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - shell: bash - env: - GEMFURY_TOKEN: ${{ secrets.GEMFURY_TOKEN }} - run: | - set -euxo pipefail - . .ci/common.sh - publish_to_pypi dist/*.whl - - - name: Upload logs on success - if: ${{ success() && hashFiles('logs/*.log') != '' }} - uses: actions/upload-artifact@v6 - with: - name: logs-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} - path: logs/*.log - - - name: Upload errors on failure - if: ${{ failure() && hashFiles('errors/*.log') != '' }} - uses: actions/upload-artifact@v6 - with: - name: errors-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} - path: errors/*.log + matrix: + python_version: ${{ fromJson(needs.detect.outputs.pythons_json) }} + uses: ./.github/workflows/build-wheels-version.yml + with: + python_version: ${{ matrix.python_version }} + archs: ${{ inputs.archs || 'android,iOS' }} + packages: ${{ needs.detect.outputs.packages }} + prebuild_recipes: ${{ inputs.prebuild_recipes || '' }} + mobile_test_pythons: ${{ inputs.mobile_test_pythons || '3.12.13' }} + python_build_run_id: ${{ inputs.python_build_run_id || '' }} + secrets: + GEMFURY_TOKEN: ${{ secrets.GEMFURY_TOKEN }} diff --git a/recipes/coolprop/meta.yaml b/recipes/coolprop/meta.yaml index 42187ef..00cccc0 100644 --- a/recipes/coolprop/meta.yaml +++ b/recipes/coolprop/meta.yaml @@ -24,18 +24,18 @@ build: -DANDROID_STL=c++_shared -DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384 -DCMAKE_MODULE_LINKER_FLAGS=-Wl,-z,max-page-size=16384 - -DPython_LIBRARY={prefix}/lib/libpython{py_version_short}.so - -DPython_INCLUDE_DIR={prefix}/include/python{py_version_short} - -DPython3_LIBRARY={prefix}/lib/libpython{py_version_short}.so - -DPython3_INCLUDE_DIR={prefix}/include/python{py_version_short} + -DPython_LIBRARY={HOST_PYTHON_HOME}/lib/libpython{py_version_short}.so + -DPython_INCLUDE_DIR={HOST_PYTHON_HOME}/include/python{py_version_short} + -DPython3_LIBRARY={HOST_PYTHON_HOME}/lib/libpython{py_version_short}.so + -DPython3_INCLUDE_DIR={HOST_PYTHON_HOME}/include/python{py_version_short} # {% else %} CMAKE_ARGS: >- -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT={{ sdk }} -DCMAKE_OSX_DEPLOYMENT_TARGET={{ sdk_version }} -DCMAKE_OSX_ARCHITECTURES={{ arch }} - -DPython_LIBRARY={prefix}/lib/libpython{py_version_short}.dylib - -DPython_INCLUDE_DIR={prefix}/include/python{py_version_short} - -DPython3_LIBRARY={prefix}/lib/libpython{py_version_short}.dylib - -DPython3_INCLUDE_DIR={prefix}/include/python{py_version_short} + -DPython_LIBRARY={HOST_PYTHON_HOME}/lib/libpython{py_version_short}.dylib + -DPython_INCLUDE_DIR={HOST_PYTHON_HOME}/include/python{py_version_short} + -DPython3_LIBRARY={HOST_PYTHON_HOME}/lib/libpython{py_version_short}.dylib + -DPython3_INCLUDE_DIR={HOST_PYTHON_HOME}/include/python{py_version_short} # {% endif %} diff --git a/recipes/flet-libcurl/build.sh b/recipes/flet-libcurl/build.sh index 36391d8..12e0ae9 100755 --- a/recipes/flet-libcurl/build.sh +++ b/recipes/flet-libcurl/build.sh @@ -1,13 +1,36 @@ #!/bin/bash set -eu +# OpenSSL discovery, for 3 known layouts across python-build versions: +# 1. host-dep extraction (iOS): the openssl wheel is declared in +# `requirements.host`, pip extracts it into $PLATLIB/opt — so headers +# land at $PLATLIB/opt/include/openssl/ssl.h and libs at +# $PLATLIB/opt/lib/libssl.{a,so}. OPENSSL_PREFIX="$PLATLIB/opt" + +# 2. python-build 3.12/3.13 (Android): openssl is bundled directly +# into the python install dir, so $PYTHON_PREFIX itself acts as the +# prefix and headers live at $PYTHON_PREFIX/include/openssl/ssl.h. if [ ! -f "$OPENSSL_PREFIX/include/openssl/ssl.h" ] || \ { [ ! -f "$OPENSSL_PREFIX/lib/libssl.a" ] && [ ! -f "$OPENSSL_PREFIX/lib/libssl.so" ]; } || \ { [ ! -f "$OPENSSL_PREFIX/lib/libcrypto.a" ] && [ ! -f "$OPENSSL_PREFIX/lib/libcrypto.so" ]; }; then OPENSSL_PREFIX="$PYTHON_PREFIX" fi +# 3. python-build 3.14+ (Android): openssl lives as a *sibling* of the +# python install dir (e.g. .../install/android//openssl-3.0.20-1). +# Glob siblings of $HOST_PYTHON_HOME (the support-tree python install +# dir, distinct from $PYTHON_PREFIX which on 3.14 relocates into the +# cross-venv), and take the first match. +if [ ! -f "$OPENSSL_PREFIX/include/openssl/ssl.h" ]; then + for candidate in "$HOST_PYTHON_HOME"/../openssl-*; do + if [ -f "$candidate/include/openssl/ssl.h" ]; then + OPENSSL_PREFIX="$candidate" + break + fi + done +fi + PKG_CONFIG=false ./configure --host=$HOST_TRIPLET --prefix=$PREFIX --with-openssl="$OPENSSL_PREFIX" make -j $CPU_COUNT make install diff --git a/recipes/flet-libfreetype/meta.yaml b/recipes/flet-libfreetype/meta.yaml index 6c25ec8..7ba1b29 100644 --- a/recipes/flet-libfreetype/meta.yaml +++ b/recipes/flet-libfreetype/meta.yaml @@ -6,7 +6,7 @@ build: number: 10 source: - url: https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.gz + url: https://downloads.sourceforge.net/project/freetype/freetype2/2.13.3/freetype-2.13.3.tar.gz patches: - config.patch diff --git a/recipes/flet-libgdal/build.sh b/recipes/flet-libgdal/build.sh index ccf87b2..39e70b1 100755 --- a/recipes/flet-libgdal/build.sh +++ b/recipes/flet-libgdal/build.sh @@ -1,6 +1,22 @@ #!/bin/bash set -eu +# SQLite3 discovery for Android: +# - 3.12/3.13: sqlite3.h is bundled inside the python install dir, +# so $HOST_PYTHON_HOME/include/sqlite3.h works. +# - 3.14+: sqlite3.h lives in a sibling dir alongside the python +# install (.../install/android//sqlite-X.Y.Z/include/). +# The .so library itself stays inside $HOST_PYTHON_HOME/lib/ on both. +SQLITE3_INC="$HOST_PYTHON_HOME/include" +if [ ! -f "$SQLITE3_INC/sqlite3.h" ]; then + for candidate in "$HOST_PYTHON_HOME"/../sqlite-*; do + if [ -f "$candidate/include/sqlite3.h" ]; then + SQLITE3_INC="$candidate/include" + break + fi + done +fi + mkdir build cd build @@ -18,8 +34,8 @@ if [ $CROSS_VENV_SDK == "android" ]; then -DCMAKE_FIND_USE_CMAKE_SYSTEM_PATH=NO \ -DPROJ_LIBRARY=$PLATLIB/opt/lib/libproj.so \ -DPROJ_INCLUDE_DIR=$PLATLIB/opt/include \ - -DSQLite3_LIBRARY=$PYTHON_PREFIX/lib/libsqlite3_python.so \ - -DSQLite3_INCLUDE_DIR=$PYTHON_PREFIX/include \ + -DSQLite3_LIBRARY=$HOST_PYTHON_HOME/lib/libsqlite3_python.so \ + -DSQLite3_INCLUDE_DIR=$SQLITE3_INC \ -DGDAL_BUILD_OPTIONAL_DRIVERS=OFF \ -DOGR_BUILD_OPTIONAL_DRIVERS=OFF \ -DGDAL_USE_EXPAT=OFF \ diff --git a/recipes/flet-libproj/build.sh b/recipes/flet-libproj/build.sh index 64e77fb..a25c19e 100755 --- a/recipes/flet-libproj/build.sh +++ b/recipes/flet-libproj/build.sh @@ -1,6 +1,22 @@ #!/bin/bash set -eu +# SQLite3 discovery for Android: +# - 3.12/3.13: sqlite3.h is bundled inside the python install dir, +# so $HOST_PYTHON_HOME/include/sqlite3.h works. +# - 3.14+: sqlite3.h lives in a sibling dir alongside the python +# install (.../install/android//sqlite-X.Y.Z/include/). +# The .so library itself stays inside $HOST_PYTHON_HOME/lib/ on both. +SQLITE3_INC="$HOST_PYTHON_HOME/include" +if [ ! -f "$SQLITE3_INC/sqlite3.h" ]; then + for candidate in "$HOST_PYTHON_HOME"/../sqlite-*; do + if [ -f "$candidate/include/sqlite3.h" ]; then + SQLITE3_INC="$candidate/include" + break + fi + done +fi + if [ $CROSS_VENV_SDK == "android" ]; then cmake \ -DCMAKE_SYSTEM_NAME=Android \ @@ -15,8 +31,8 @@ if [ $CROSS_VENV_SDK == "android" ]; then -DTIFF_INCLUDE_DIR="$PLATLIB/opt/include" \ -DCURL_LIBRARY="$PLATLIB/opt/lib/libcurl.so" \ -DCURL_INCLUDE_DIR="$PLATLIB/opt/include" \ - -DSQLite3_LIBRARY=$PYTHON_PREFIX/lib/libsqlite3_python.so \ - -DSQLite3_INCLUDE_DIR=$PYTHON_PREFIX/include + -DSQLite3_LIBRARY=$HOST_PYTHON_HOME/lib/libsqlite3_python.so \ + -DSQLite3_INCLUDE_DIR=$SQLITE3_INC else cmake \ -DCMAKE_SYSTEM_NAME=iOS \ diff --git a/setup.sh b/setup.sh index 9a9201e..59e147c 100755 --- a/setup.sh +++ b/setup.sh @@ -91,6 +91,52 @@ download_support() { echo "Extracting ${tarball} into ${dest}..." mkdir -p "$dest" tar -xzf "downloads/${tarball}" -C "$dest" + + # Rewrite the `prefix=` line in every shipped `lib/pkgconfig/*.pc` to pkg-config's relocatable + # form `prefix=${pcfiledir}/../..` so consumer pkg-config invocations resolve include/lib paths to the actual + # on-disk install root, NOT the build-time `/usr/local` autoconf default that CPython bakes in. Without + # this, meson's `py.dependency()` gets `-I/usr/local/include/python3.X` (a path that does not exist on + # the CI runner) and reports the Python dep as "not found" — surfaced by numpy 2.4.6 on Python 3.14 Android. + relocate_pkgconfig_prefix "$dest" +} + +# Walk every `.pc` file under /.../lib/pkgconfig/ and rewrite the (literal absolute-path) `prefix=` +# line to pkg-config's standard relocatable `prefix=${pcfiledir}/../..`. Idempotent — if the line is +# already in the relocatable form, the sed substitution silently leaves the file alone. Safe to call +# across versions/platforms; the find prunes to pkgconfig dirs explicitly. +relocate_pkgconfig_prefix() { + local dest="$1" + # Find every lib/pkgconfig dir under the extracted tree. CPython ships .pc files under + # //python-/lib/pkgconfig on Android and under //lib/pkgconfig on iOS. + find "$dest" -type d -name pkgconfig 2>/dev/null | while read -r pcdir; do + # `prefix=${pcfiledir}/../..` -- pkg-config/pkgconf substitutes ${pcfiledir} with the .pc + # file's actual directory at lookup time. /../.. on a .pc at /lib/pkgconfig/*.pc + # resolves to -- the consumer's real install prefix. + # + # Also substitute `$(BLDLIBRARY)` in the Libs: line. CPython's autoconf-built python-X.Y.pc + # ships `Libs: -L${libdir} $(BLDLIBRARY)` -- the `$(BLDLIBRARY)` is supposed to expand to `-lpython3.X` + # at install time but never does, and pkg-config passes the literal through to the linker which then + # fails with `clang++: error: no such file or directory: '$(BLDLIBRARY)'`. Only the python-X.Y.pc files + # are affected; python-X.Y-embed.pc already has `-lpythonX.Y` written directly. We fix both + # idempotently by rewriting `$(BLDLIBRARY)` -> `-lpython${ver}` where ver is derived from the .pc filename. + for pc in "$pcdir"/*.pc; do + [ -f "$pc" ] || continue + # macOS sed doesn't have -i without an extension arg; use a portable in-place edit via a temp file. + local tmp + tmp="$(mktemp)" + sed -E 's|^prefix=.*|prefix=${pcfiledir}/../..|' "$pc" > "$tmp" && mv "$tmp" "$pc" + + # If this is a python-X.Y.pc (not the -embed variant or some other recipe-shipped .pc), substitute + # $(BLDLIBRARY) with the matching -lpythonX.Y. + local base + base="$(basename "$pc")" + if [[ "$base" =~ ^python-([0-9]+\.[0-9]+)\.pc$ ]]; then + local ver="${BASH_REMATCH[1]}" + tmp="$(mktemp)" + sed -E 's|\$\(BLDLIBRARY\)|-lpython'"$ver"'|g' "$pc" > "$tmp" && mv "$tmp" "$pc" + fi + done + done } # Echo the directory that actually contains the support/ tree: $1 itself, or a @@ -273,4 +319,4 @@ echo " forge iOS --all-versions lru-dict" echo # The script is sourced; don't leave helper functions in the user's shell. -unset -f download_support resolve_support_root +unset -f download_support resolve_support_root relocate_pkgconfig_prefix diff --git a/src/forge/build.py b/src/forge/build.py index 76c6917..035bb00 100644 --- a/src/forge/build.py +++ b/src/forge/build.py @@ -234,8 +234,12 @@ def prepare(self, clean=True): ) shutil.rmtree(self.build_path) + # Re-download sources if caching is disabled or no cached tarball exists. + # By default, the cached tarball is reused across arch builds to avoid downloading + # the same source multiple times. Disable caching when testing source-tarball patches, + # since each arch reuses and re-unpacks the same cached archive. if ( - os.getenv(f"MOBILE_FORGE_CACHE_DOWNLOADS_OFF") + os.getenv("MOBILE_FORGE_CACHE_DOWNLOADS_OFF") or not self.source_archive_path.is_file() ): log(self.log_file, f"\n[{self.cross_venv}] Download package sources") @@ -384,7 +388,13 @@ def compile_env(self, **kwargs) -> dict[str, str]: if (self.cross_venv.sdk_root / "usr" / "lib").is_dir(): ldflags += f" -L{self.cross_venv.sdk_root}/usr/lib" - # Add the framework path + # Add the framework search path. We do *not* append `-framework Python` + # here -- doing so breaks autoconf-based builds like flet-libfreetype, whose + # `./configure` probes the C compiler by linking a trivial hello.c against $LDFLAGS; + # `-framework Python` makes that probe fail with `configure: error: C compiler cannot create executables`. + # Cargo/setuptools/meson recipes each get the framework link via their own channel (cargo_ldflags + # below, the cross sysconfig'\''s LDSHARED for setuptools, and the meson cross-file'\''s + # c_link_args / cpp_link_args augmented in _create_meson_cross for meson). ldflags += f' -F "{self.cross_venv.host_python_home}"' cargo_ldflags += f" -C link-arg=-F{self.cross_venv.host_python_home} -C link-arg=-framework -C link-arg=Python" @@ -400,6 +410,42 @@ def compile_env(self, **kwargs) -> dict[str, str]: else self.cross_venv.platform_triplet ) + # Point pkg-config at the pkgconfig dirs that matter for the target build: + # 1. host_python_home/lib/pkgconfig — where python-X.Y.pc lives. + # Required for meson's `py.dependency()` to resolve the + # Python C dep via the relocated `.pc` files. + # 2. install_root/lib/pkgconfig — where flet-lib* extract their + # own `.pc` files when installed as host wheels. + # 3. /{build,cross}/lib/pythonX.Y/site-packages/*/share/pkgconfig + # — pure-Python wheels (pybind11, …) ship their .pc files bundled + # inside site-packages rather than the usual lib/pkgconfig dir, so + # `dependency('pybind11')` via meson's pkg-config method only resolves + # once the .pc dir under the installed wheel is added explicitly. + # All three sets of `.pc` files use `prefix=${pcfiledir}/../..` so + # pkg-config emits paths relative to the on-disk .pc location. + pkg_config_paths = [] + python_pc_dir = self.cross_venv.host_python_home / "lib" / "pkgconfig" + if python_pc_dir.is_dir(): + pkg_config_paths.append(str(python_pc_dir)) + pc_dir = install_root / "lib" / "pkgconfig" + if pc_dir.is_dir(): + pkg_config_paths.append(str(pc_dir)) + if self.cross_venv.venv_path.is_dir(): + py_short = f"python3.{sys.version_info.minor}" + for env_root in ("build", "cross"): + site_dir = ( + self.cross_venv.venv_path + / env_root + / "lib" + / py_short + / "site-packages" + ) + if site_dir.is_dir(): + for share_pkgconfig in site_dir.glob("*/share/pkgconfig"): + if share_pkgconfig.is_dir(): + pkg_config_paths.append(str(share_pkgconfig)) + pkg_config_path = ":".join(pkg_config_paths) + env = { "AR": ar, "CC": cc, @@ -409,6 +455,18 @@ def compile_env(self, **kwargs) -> dict[str, str]: "CFLAGS": cflags, "CPPFLAGS": cppflags, "LDFLAGS": ldflags, + "PKG_CONFIG_PATH": pkg_config_path, + # PKG_CONFIG_LIBDIR overrides pkg-config's *default* search list + # (typically /opt/homebrew/lib/pkgconfig + /usr/lib/pkgconfig on + # macOS, /usr/lib/pkgconfig on Linux). Without it, recipes like + # Pillow that scan via pkg-config will happily resolve libtiff / + # liblcms2 / libpng to the build host's macOS dylibs and try to + # link them into iOS .so files -- the linker then aborts with + # "ld: building for 'iOS', but linking in dylib (...) built for + # 'macOS'". Point LIBDIR at the same support-tree-only paths + # PKG_CONFIG_PATH already enumerates so pkg-config can't even + # see Homebrew's pkgconfig dir. + "PKG_CONFIG_LIBDIR": pkg_config_path, "CROSS_VENV_SDK": self.cross_venv.sdk, "CARGO_BUILD_TARGET": cargo_build_target, "CARGO_TARGET_{}_LINKER".format( @@ -429,6 +487,16 @@ def compile_env(self, **kwargs) -> dict[str, str]: else Path(self.cross_venv.sysconfig_data["prefix"]) / "lib" ) ), + # The on-disk python install directory for the target SDK / + # arch inside the mobile-forge support tree + # (`MOBILE_FORGE__SUPPORT_PATH/install///python-` + # on Android, the matching Python.xcframework slice on + # iOS). Always a real directory on disk -- useful when a + # recipe needs to locate sibling artifacts shipped alongside + # Python in the support tree, or to pin Python_LIBRARY / + # Python_INCLUDE_DIR against a path that doesn't move with + # crossenv relocation across python-build versions. + "HOST_PYTHON_HOME": str(self.cross_venv.host_python_home), } env.update(kwargs) @@ -616,7 +684,7 @@ def _rewrite_absolute_needed(self, so_path: Path): self.log_file, f"[{self.cross_venv}] {so_path.name}: NEEDED " f"'{name.decode(errors='replace')}' -> " - f"'{name[slash + 1:].decode(errors='replace')}'", + f"'{name[slash + 1 :].decode(errors='replace')}'", ) def _check_elf_alignment(self, so_path: Path): @@ -658,21 +726,11 @@ def fix_wheel(self, wheel_dir: Path): # Normalize wheel tags to forge platform tags so repacked wheels use # android_24_arm64_v8a / ios_13_0_arm64_iphoneos style platform tags. - # Preserve the Python/ABI part the upstream build wrote (e.g. maturin - # emits `cp37-abi3-*` for cryptography); only the platform component - # is swapped. Falls back to self.wheel_tag when no Tag was written. wheel_metadata_path = next(wheel_dir.glob("*.dist-info")) / "WHEEL" wheel_metadata = self.read_message_file(wheel_metadata_path) - upstream_tags = wheel_metadata.get_all("Tag", []) - del wheel_metadata["Tag"] - new_tags = [] - for tag in upstream_tags: - py, abi, _platform = tag.rsplit("-", 2) - new_tags.append(f"{py}-{abi}-{self.cross_venv.tag}") - if not new_tags: - new_tags = [self.wheel_tag] - for tag in new_tags: - wheel_metadata["Tag"] = tag + if "Tag" in wheel_metadata: + del wheel_metadata["Tag"] + wheel_metadata["Tag"] = self.wheel_tag self.write_message_file(wheel_metadata_path, wheel_metadata) if self.cross_venv.sdk == "android": @@ -973,12 +1031,31 @@ def _create_meson_cross(self, env: dict[str, str]): / "bin" / f"python3.{sys.version_info.minor}" ), + # Declare pkg-config explicitly. Meson cross-compile mode otherwise treats + # pkg-config as a build-machine-only tool and refuses to use it for target dep + # resolution -- even when it's installed and on PATH. Without this declaration + # meson reports "Found pkg-config: NO" regardless, defeats `py.dependency()` via + # pkg-config, and falls through to the sysconfig path that 3.14 doesn't tolerate the + # autoconf-baked `/usr/local` paths in. With it declared, meson invokes + # pkg-config + honors PKG_CONFIG_PATH (set in compile_env() above), reads the + # relocated `.pc` file, and emits the consumer-correct -I/-L flags. + "pkg-config": "pkg-config", }, "built-in options": { "c_args": env["CFLAGS"], "cpp_args": env["CPPFLAGS"], - "c_links_args": env["LDFLAGS"], - "cpp_links_args": env["LDFLAGS"], + # iOS: append `-framework Python` to the meson c/cpp link args (not LDFLAGS env) so + # meson recipes (numpy, contourpy with pybind11, …) resolve the Python C API at link time + # without breaking autoconf-based builds whose hello.c probe also reads $LDFLAGS. + # See compile_env() in this file for the matching half of this split. + "c_links_args": ( + env["LDFLAGS"] + + (" -framework Python" if self.cross_venv.host_os == "iOS" else "") + ), + "cpp_links_args": ( + env["LDFLAGS"] + + (" -framework Python" if self.cross_venv.host_os == "iOS" else "") + ), }, "properties": {"needs_exe_wrapper": False}, "host_machine": { diff --git a/src/forge/cross.py b/src/forge/cross.py index 79a270f..697cb64 100644 --- a/src/forge/cross.py +++ b/src/forge/cross.py @@ -430,6 +430,9 @@ def cross_kwargs(self, kwargs): str(self.venv_path / "bin"), str(self.venv_path / self.venv_path.name / "bin"), str(Path.home() / ".cargo/bin"), + "/opt/homebrew/bin", + # For Intel-mac or older CI runner images that put Homebrew under /usr/local. + "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin",