Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2c90d13
forge: allow source.strip on url-download sources
ndonkoHenri Aug 13, 2026
b95d4ee
recipes: curl-cffi 0.16.0 + flet-libcurl-impersonate 2.0.0
ndonkoHenri Aug 13, 2026
7a9686f
skills: capture curl-cffi patterns (cffi-consumer-of-prebuilt, iOS Ap…
ndonkoHenri Aug 13, 2026
62b9b76
Merge remote-tracking branch 'origin/main' into curl-cffi
ndonkoHenri Aug 31, 2026
e0d3fdb
recipe: ship the curl-impersonate licence notices [skip ci]
ndonkoHenri Aug 31, 2026
e8c2200
recipes: curl-cffi 0.16.2 + flet-libcurl-impersonate 2.1.1 [skip ci]
ndonkoHenri Aug 31, 2026
f92d13b
docs: curl-cffi consumer README + fingerprint-fanout example [skip ci]
ndonkoHenri Aug 31, 2026
b64e867
skills: the licence gate's no-notice-at-all case [skip ci]
ndonkoHenri Aug 31, 2026
01112f5
docs: pin the example to 3.12, and fix what a failed probe renders [s…
ndonkoHenri Aug 31, 2026
38e4093
skills: what `build.number: 0` actually costs, and the certifi non-go…
ndonkoHenri Aug 31, 2026
c2be3ed
docs: curl-cffi coverage gaps now that CI is green [skip ci]
ndonkoHenri Aug 31, 2026
9c804af
forge: only warn about `about` when a recipe actually declares one
ndonkoHenri Aug 31, 2026
29f87c4
Merge branch 'forge/about-warning' into curl-cffi
ndonkoHenri Aug 31, 2026
68de424
improve
ndonkoHenri Aug 31, 2026
00f9d29
forge: collect a recipe's licenses/ folder, and move the vendored not…
ndonkoHenri Aug 31, 2026
7ccb78c
docs: link curl-cffi's API references, drop the Build notes preamble …
ndonkoHenri Aug 31, 2026
89313d8
docs: measure which wheel pip prefers, and stop pinning the example t…
ndonkoHenri Aug 31, 2026
7abf9ad
skills: the forge-shadows-official rule holds only at equal versions …
ndonkoHenri Aug 31, 2026
815d1ee
ci: trigger a run on this branch
ndonkoHenri Aug 31, 2026
1514255
Merge branch 'main' into curl-cffi
ndonkoHenri Aug 31, 2026
a986866
improve pycares example
ndonkoHenri Aug 31, 2026
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
5 changes: 5 additions & 0 deletions .claude/skills/forge-error-catalogue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ instead of re-deriving it.
hand-written + gen2.py-generated code), **opencv-5 KleidiCV `armv8-a` on x86_64**
and **hardcoded `CMAKE_SYSTEM_PROCESSOR` → ARM asm on the x86_64 sim** (per-arch
fix), **Rust crate with no `target_os="ios"` backend** (`mac_address`, cfg-gate it),
**iOS undefined `_SecTrust*`/`_iconv`/`_uidna_*` linking a prebuilt Apple-built
static lib → `-framework Security -framework CoreFoundation -liconv -licucore`**
(curl-impersonate archive; iOS-only, the macOS build omits them),
**`source.url` prebuilt tarball unpacks with root-level `.a`/`.so` MISSING →
`source.strip: 0`** (default strip=1 drops files with no wrapper dir),
**`User for pypi.flet.dev:` → `EOFError` at build-tool/host-dep resolution** (the
index 401s, not the recipe — deterministic locally on a fresh cmake cross-venv,
transient/scattered in CI where a rerun clears it). **A vendored lib built by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,36 @@ If upstream hardcodes `-L/usr/lib/X` somewhere, write a `mobile.patch` to strip

---

### iOS `Undefined symbols: _SecTrustEvaluateWithError / _iconv / _uidna_nameToASCII_UTF8 / _kCFTypeArrayCallBacks` linking a prebuilt Apple-built static lib

**Cause:** a prebuilt static archive built for iOS with Apple's native TLS trust store (`USE_APPLE_SECTRUST`) and/or Apple IDN (`USE_APPLE_IDN`) — e.g. curl-impersonate's `libcurl-impersonate.a` — has undefined references into Apple **system** libraries that its objects do NOT carry. When you statically link that `.a` into a Python extension, those symbols surface as `ld: Undefined symbols for architecture arm64`. The symbol → library map:

- `_SecTrust*`, `_SecCertificate*`, `_SecPolicy*` → `-framework Security`
- `_kCFType*`, `_CFRelease`, `CFString*`, `CFArray*` → `-framework CoreFoundation`
- `_iconv`, `_iconv_open`, `_iconv_close` → `-liconv`
- `_uidna_*` (ICU International Domain Names) → `-licucore`

(The real-macOS build of the same package usually does NOT need these — macOS uses a different verify/IDN path — so upstream's link args omit them, and this is iOS-only.)

**Fix:** append the frameworks/libs to the extension's link args, gated to iOS. They resolve from the SDK sysroot (`.tbd` stubs in `usr/lib` + `System/Library/Frameworks`) with no extra `-L`/`-F`. In a cffi recipe this is a `mobile.patch` hunk on the `extra_link_args` list; in a `setup.py`/CMake recipe it can also ride in `script_env` `LDFLAGS`. Precedent: `recipes/curl-cffi/patches/mobile.patch` (branch `curl-cffi`) adds `["-framework","Security","-framework","CoreFoundation","-liconv","-licucore"]` for the iOS slice only.

---

### `source.url` prebuilt tarball unpacks with the top-level files MISSING (`.a`/`.so` gone, only `include/…` present)

**Cause:** forge's `unpack_source` defaults to `strip=1` (`member.path.split("/", 1)[1]`), which assumes a single top-level wrapper directory. A prebuilt **release** tarball often has its files at the archive **root** (`libX.a`, `libX.so`, `include/X/…` with no wrapper dir). At `strip=1` the root-level files have no `/` → `split(...)[1]` IndexErrors → they are silently **dropped**, while `include/x.h` → `x.h`. build.sh then can't find the `.a`.

**Symptoms:** build.sh's own guard (`[ ! -f libX.a ]`) trips, or a downstream link fails with the lib missing, even though the tarball clearly contains it.

**Fix:** set `strip: 0` on the source object so forge extracts verbatim:
```yaml
source:
url: https://.../libX-<arch>.tar.gz
strip: 0
```

---

### `ImportError: dlopen failed: library "/abs/path/.../libpython3.12.so" not found` (Android, at runtime)

**Cause:** The Android `libpython3.12.so` from the `flet-dev/python-build` tarball is built without a SONAME (no `-Wl,-soname,libpython3.12.so` at libpython link time). When a recipe uses CMake's `target_link_libraries(... Python::Python)` to link explicitly against libpython (which pyzmq does on Android via `EXTRA_PYTHON_COMPONENT=Development.Embed`), the linker can't use a SONAME and falls back to using the input argument as DT_NEEDED. With `-DPython_LIBRARY=<absolute path>`, that path gets embedded verbatim. At runtime, Android's loader looks for the absolute build-host path and fails.
Expand Down Expand Up @@ -2096,6 +2126,43 @@ about:
Note an **unset** `license_file` still means "discover for me" — only an empty **list** is
the opt-out.

**The prebuilt-repackage case: upstream ships no notice ANYWHERE.** A recipe that repackages
a third-party *binary* release often gets a tarball holding nothing but the library and its
headers — no `LICENSE`, no `COPYING`, nothing to point `license_file` at. `[]` is the wrong
reflex here: the wheel really does contain that object code, so the notice has to come from
somewhere, and the schema says so — *"or to the recipe directory, for a notice the upstream
archive doesn't ship"*. **Vendor the notices into `recipes/<name>/licenses/`** — a folder
alongside `tests/` and `examples/`, whose entire contents ship whatever each file is named:

```
recipes/flet-libcurl-impersonate/
licenses/COPYING.curl licenses/LICENSE.boringssl licenses/LICENSE.zstd ...
```

```yaml
about:
license: curl AND MIT AND Apache-2.0 AND BSD-3-Clause AND Zlib # no license_file needed
```

No `license_file` list: the folder is the list, so it cannot fall out of step on a bump.
The folder name is stripped from the destination, so notices land at
`dist-info/licenses/<name>`.

Getting the component list right is the actual work, and a mega-archive will not tell you
directly: `ar t` on a `ld -r` blob lists one member. Recover it from the symbols the linker
left behind, then corroborate against upstream's build config:

```bash
llvm-nm -a libfoo.a | awk '$2=="-"||$2=="f"{print $3}' | sort -u # STT_FILE syms per project
strings libfoo.a | grep -oE '/build/deps/src/[a-z0-9-]+' | sort | uniq -c
```

Two traps when picking each file, both the libiconv trap in different directions: a project's
`LICENSE` may be a stub redirect (nghttp2's is 12 bytes, "See COPYING"), and a dual-licensed
project's `COPYING` may be the arm we do *not* take (zstd's is the full GPL-2.0; the BSD arm
is in `LICENSE`). Fetch every file at the **pinned version tag**, never `main` — the texts
carry copyright year ranges that drift.

**Related:** `about.license_file` naming a file that isn't there raises too (a typo or a
moved file, not a preference). Changing licence metadata does not reach pypi.flet.dev until
the recipe's **build number is bumped** — see `forge-ci` § Deploying, "Bump before
Expand Down
10 changes: 9 additions & 1 deletion .claude/skills/local-recipe-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,15 @@ Upstream packages now publish cibuildwheel-built iOS/Android wheels to PyPI (cp3
only). They are live pip candidates in every 0.86 build, **but while a forge recipe
exists on pypi.flet.dev it deterministically shadows them** — pip's sort at equal
versions is tag-priority (forge `android_24` > official `android_21`) then build tag
(forge `-1-` > none). To force the official wheel on-device without touching the index:
(forge `-1-` > none). **Only at equal versions.** Version is compared before any tag, so
an upstream release the recipe has not caught up to wins outright — and since upstream
publishes arm64-v8a alone, the result is a MIXED app: their arm64 beside forge's x86_64,
one version apart. A stale recipe is the failure mode here, not a resolution quirk.
Measured 2026-08-31 with `pip download --only-binary :all: --platform
android_24_arm64_v8a --python-version 313` against both indexes: `pyzmq==27.1.0` (same
platform tag, build tag alone between them) and `lru-dict==1.4.1` (`android_24` vs
`android_21`) both resolve to the forge wheel; unpinned `pyzmq` resolves to upstream's
newer 27.2.0. To force the official wheel on-device without touching the index:
retag a downloaded copy into a find-links dir with a HIGH build tag — and on Android
also lift the platform tag past forge's —

Expand Down
22 changes: 21 additions & 1 deletion .claude/skills/new-mobile-recipe/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Match the package to one of these shapes. Each maps to a template in `templates/
| **setup.py that drives CMake itself** for a vendored native lib | An sdist that vendors a C library and builds it with its own `subprocess` CMake call inside `build_ext`, then links the static result via `extra_objects` (pycares→c-ares). Not scikit-build-core — the arg list is hardcoded in `setup.py` | No template — copy `recipes/pycares/`: one patch appends `shlex.split(os.environ['FORGE_CMAKE_ARGS'])` to the arg list, `requirements.build: [cmake]`; see "vendored-CMake" deep-dive below |
| CMake giant, **no sdist AND no setup.py/pyproject.toml** | Upstream's only wheel path is a host==target build script (onnxruntime's `ci_build/build.py`, TF's `build_pip_package_with_cmake.sh`) | No template — copy from `recipes/onnxruntime/` or `recipes/tflite-runtime/` (branches `machine/onnxruntime` / `machine/tflite-runtime`); see "PEP 517 shim" deep-dive below |
| **Prebuilt-repackage + host_build chain** | Upstream publishes official prebuilt mobile archives of the native lib AND the consumer's own cmake links + re-ships the `.so` (flet-libonnxruntime→sherpa-onnx) | `build.sh` repackager + consumer `requirements.host_build`; copy from `recipes/flet-libonnxruntime/` + `recipes/sherpa-onnx/` (branch `machine/sherpa-onnx`); see "prebuilt-repackage" deep-dive below |
| **cffi/ctypes consumer of a prebuilt archive** | The package's own build DOWNLOADS a prebuilt static lib keyed by the build host's `uname` and statically links it (curl-cffi → curl-impersonate); breaks under cross because host≠target | `flet-lib*` prebuilt-repackage dep (`source.strip: 0` for root-level tarballs) + `requirements.host_build` + a `mobile.patch` opt-in env lever that steers arch/link off the forge target; copy from `recipes/flet-libcurl-impersonate/` + `recipes/curl-cffi/` (branch `curl-cffi`); see deep-dive below |

If unsure, start with **minimal C-extension** and let the build tell you what's missing. Iterate up the table as failures surface.

Expand Down Expand Up @@ -143,6 +144,18 @@ The shim's load-bearing rules (each one bought with a failed build):

**Real example:** `machine/sherpa-onnx:recipes/flet-libonnxruntime/` + `machine/sherpa-onnx:recipes/sherpa-onnx/` (patches `android-no-jni.patch` + `android-preload-ort.patch`).

### Shape deep-dive: cffi/ctypes consumer that self-detects arch + downloads a prebuilt archive (curl-cffi → flet-libcurl-impersonate)

**When:** the Python package's own build **downloads a prebuilt static lib keyed by the build host's `platform.uname()`** and statically links it (curl-cffi's `scripts/build.py` matches `uname` against a `libs.json`, downloads `libcurl-impersonate-v{ver}.{arch}-{sysname}.tar.gz`, and `--whole-archive`/`-force_load`s it into a cffi extension). This CANNOT work unpatched under forge: the build host (macOS/Linux) is never the wheel's target, so `uname`-based arch selection and any host-`platform.system()`-driven link recipe are wrong for every slice.

The shape that works — a `flet-lib*` prebuilt-repackage dep + a small opt-in patch:

1. **A `flet-lib*` recipe stages the prebuilt archive per slice** via `source.url` (one tarball per `(sdk, arch)` — Jinja-map `arm64-v8a`→`aarch64` etc.) into `$PREFIX` (=`wheel/opt`), so the consumer sees `{platlib}/opt/`. **`source.strip: 0`** when the tarball's files sit at the archive **root** with no wrapper dir (a prebuilt `.a` beside an `include/` — forge's default `strip=1` splits `path.split("/",1)[1]` and IndexError-**drops** every root-level file, keeping only `include/…`→`…`; verify empirically, it fails silently otherwise). Each upstream tarball is usually already thin per-slice → no `lipo`. `excluded_arches` any slice with no upstream binary (curl-impersonate has no `armeabi-v7a`).
2. **The consumer declares it `requirements.host_build`** and points the package's own "where's the lib" env var at it (`IMPERSONATE_BUILD_DIR: '{platlib}/opt'`) so the package's **download is skipped** (the `.a` already exists there) — fully offline, and no CI `tmplibdir`-reuse arch leak.
3. **A minimal `mobile.patch` adds ONE opt-in lever** (an env var the recipe's `script_env` sets, e.g. `IMPERSONATE_FORGE_TARGET: ios|android`) that (a) makes the arch-detect return a synthesized target entry instead of scanning `uname`, and (b) forces the target's static-link recipe (Apple `-force_load`+`-lc++` for iOS, ELF `--whole-archive`+`-lc++` for Android — the Android branch is the one the macOS host gets wrong). Guard it so upstream behaviour is unchanged when the var is unset. **iOS extra:** a static archive built with Apple SecTrust / Apple IDN needs the consuming extension to link `-framework Security -framework CoreFoundation -liconv -licucore` (see `forge-error-catalogue` § iOS undefined `_SecTrust*`/`_iconv`/`_uidna_*`).

**Real example:** branch `curl-cffi` — `recipes/flet-libcurl-impersonate/` (build.sh, `source.strip: 0`) + `recipes/curl-cffi/` (`patches/mobile.patch`, 3 hunks). World-first curl-cffi iOS wheels; on-device 4/4 both platforms, and a real impersonated HTTPS request returns 200 on each. Needed one forge-core change: exposing `source.strip` in `src/forge/schema/meta-schema.yaml` (the code already honored it).

### Naming

- Recipe directory name and `package.name` in meta.yaml must match the PyPI sdist filename exactly (case-sensitive). `MarkupSafe`, not `markupsafe`. `cffi`, not `CFFI`. Look at `<pypi-name>-<version>.tar.gz` filename on PyPI for the ground truth.
Expand Down Expand Up @@ -304,7 +317,14 @@ accompany the binary, forge now bundles one automatically: any top-level
copied into `.dist-info/licenses/` and listed as `License-File`. That covers almost every
upstream unchanged — so usually you write nothing.

Three cases need a line in `meta.yaml`:
**When upstream ships no notice at all** — the usual case for a prebuilt-binary
repackage — put one file per bundled project in `recipes/<name>/licenses/`. Everything in
that folder ships, under any name, and the folder name is stripped from the destination.
Prefer it over a `license_file` list and over loose files in the recipe root: the folder
IS the list, so it cannot go stale on a bump, and the recipe root stays readable.
Precedent: `recipes/flet-libcurl-impersonate/licenses/` (nine notices, no list).

Three cases still need a line in `meta.yaml`:

- **The notice is not at the top level, or is named something else** — point at it:
```yaml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,9 @@ So:

Integer in `build:`, schema default **1** (`src/forge/schema/meta-schema.yaml`) — and `1` is the repo convention (start every new recipe there). Bump when the recipe itself changes but the upstream version doesn't — pip prefers higher build numbers for the same version, and the new build gets a distinct filename (`<pkg>-<ver>-N-<tag>.whl`). Important for forcing redeploys when patching.

Don't write `number: 0`: forge only passes `--build-number` when the value is truthy (`build.py`), so `0` produces a wheel with NO build tag — losing the ability to supersede it later without a version bump.
Don't write `number: 0`: forge only passes `--build-number` when the value is truthy (`build.py`), so `0` produces a wheel with NO build tag — losing the ability to supersede it later without a version bump. It also forfeits a **PEP 427 tie-break you may be relying on**: pypi.flet.dev is an *extra* index, not a replacement, so where upstream publishes its own mobile wheel at the same version (curl-cffi ships `cp313`/`cp314` `android_24_arm64_v8a` on PyPI), an untagged forge wheel is competing with one hand tied. **CI cannot catch any of this** — the mobile test rewrites local wheels' build tag to `9999` before installing, so a `number: 0` recipe still goes green. Only the filename in `dist/` tells you.

On an upstream **version** bump, reset `number` to 1 rather than carrying the old value forward.

### Source layout

Expand Down
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ Inside the recipe directory, add the following files.
pytest suite which imports the package and does some basic checks.
* Optionally, one or more patch files in a folder named ``patches``. These patches will be
applied when the source code is unpacked for a given platform.
* Optionally, a folder named ``licenses``. Every file in it is copied into the wheel's
``.dist-info/licenses/`` under its own name, and the folder name is not part of that
destination. Use it when the upstream archive ships no licence text of its own — a
prebuilt binary release, usually — and the recipe has to supply one notice per bundled
project. A licence-shaped file at the top level of the source or recipe directory is
picked up without this, so most recipes need neither.
* For non-Python packages, a ``build.sh`` script. This is the script that will be executed
in the build environment build the package. This script should invoke any ``configure``,
``make``, or any other compilation steps needed to build the package. This script will be
Expand Down
Loading
Loading