From c744ad45da7348814d3d0ecee275d0043d464498 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 21 Aug 2026 19:14:05 +0200 Subject: [PATCH 01/11] forge: ship a licence with every build.sh wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flet-lib*` wheels have been going out with **no licence information at all** — no `License` field, and no licence file inside the wheel. Checked against what is published today: flet-libgeos (GEOS, LGPL-2.1) METADATA licence fields: none flet-libiconv (GNU libiconv, LGPL) licence files in wheel: none Python packages are unaffected: their build backend copies upstream's metadata and licence files into the wheel and forge preserves both (verified against the published `zeroconf` wheel, which carries `License-Expression` and its `COPYING`). The gap is only the build.sh path, where `make_wheel` synthesises METADATA by hand — `Metadata-Version`, `Name`, `Version`, an empty `Summary` and an empty `Download-URL`, and nothing else. Every licence in this tree requires its notice to accompany the binary. That is the least ambiguous obligation any of them carries and the cheapest to satisfy, and we were not satisfying it. ## The `about` key was documented but dead `meta.yaml` already had `about.license_file`, whose own schema description promises that "build-wheel will automatically include any file in the source or recipe directory whose name starts with LICEN[CS]E or COPYING". Nothing in `src/forge/` read `about` at all. `flet-libfreetype` has been setting `license_file: docs/FTL.TXT` to no effect since it was written. This makes the documented behaviour real. ## What now happens - Any top-level `LICEN[CS]E*` / `COPYING*` / `COPYRIGHT*` / `NOTICE*` in the **source** or the **recipe** directory is copied into `.dist-info/licenses/` and listed as `License-File`. A source file shadows a recipe file of the same name, so a recipe can carry a fallback without overriding upstream. - `COPYRIGHT` is matched because for `libpq`, `libxml2` and `libxslt` it *is* the licence grant and the only such file shipped — each was previously ending up with nothing. - `NOTICE` is matched because Apache-2.0 section 4(d) requires redistributing it, and an ASF archive keeps its attributions there rather than in LICENSE (arrow). - `about.license_file` takes a path **or a list**, and replaces discovery entirely — for the two cases discovery cannot get right: excluding a notice that covers something the wheel does not contain, and including one below the top level. - New `about.license` holds an SPDX expression, emitted as `License-Expression`. - `Metadata-Version` goes 1.2 -> 2.4, the floor for PEP 639's `License-Expression` and `License-File`. - `write_message_file` accepts a list value, emitting the header once per item, since `License-File` is legitimately repeated. - Relative paths are preserved rather than flattened to a basename: PEP 639 records `License-File` that way, and flattening would silently drop one of two same-named notices (jq ships a `COPYING` for itself and another for the oniguruma it bundles). - Setting `about` on a **Python** recipe now warns rather than being silently ignored, since that path's metadata comes from the package's own backend. ## Verified Real builds, not reasoning: `flet-libiconv`, `flet-libgeos`, `flet-libxml2`, `flet-libxslt`, `flet-libpq`, `flet-libfreetype`, `flet-libjq`, `flet-libopenblas`, `flet-libpsl`, `flet-libcpp-shared` and `flet-libomp` (both platforms) all produce wheels carrying the expected notices and metadata. `Metadata-Version: 2.4` is the one compatibility risk, so it was tested rather than assumed: strict `packaging` 26.3 validates the result, and **pip and uv both install it** with the licence files landing in site-packages. forge's own METADATA handling uses the email parser and a regex, not `packaging.metadata`, so the older `packaging` in the build venv is not in the path. No new lint: the two flake8 findings in `build.py` predate this change and are left alone. --- .claude/skills/new-mobile-recipe/SKILL.md | 46 ++++++++ src/forge/build.py | 136 ++++++++++++++++++++-- src/forge/schema/meta-schema.yaml | 34 +++++- 3 files changed, 200 insertions(+), 16 deletions(-) diff --git a/.claude/skills/new-mobile-recipe/SKILL.md b/.claude/skills/new-mobile-recipe/SKILL.md index eea30e31..85a63bef 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -260,6 +260,52 @@ Renders the meta.yaml for all SDK contexts (iphoneos, iphonesimulator, android) with a non-matching needle silently no-ops. Always `assert needle in text` before replacing, or grep the output file for the new content after. +### 3.5b — Licences (`flet-lib*` / build.sh recipes only) + +A **Python package** carries its own licence: the build backend copies upstream's METADATA +and licence files into the wheel, and forge preserves them. Nothing to do. + +A **build.sh recipe's wheel is synthesised by forge**, so the licence only gets there +because the build does it. Since every licence in this tree requires its notice to +accompany the binary, forge now bundles one automatically: any top-level +`LICEN[CS]E*` / `COPYING*` / `COPYRIGHT*` in the **source** or the **recipe** directory is +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`: + +- **The notice is not at the top level, or is named something else** — point at it: + ```yaml + about: + license_file: docs/FTL.TXT # flet-libfreetype + ``` + An explicit `license_file` is resolved against the source dir, then the recipe dir, and + **replaces** auto-detection: use it when upstream ships several notices and only one + applies to what the wheel actually contains. +- **Machine-readable identifier**, so a licence scanner needn't unpack the wheel: + ```yaml + about: + license: LGPL-2.1-or-later # emitted as License-Expression + ``` + Never guess this. Read the licence text in the archive we ship from, note whether it says + "or (at your option) any later version" (`-or-later`) or names one version (`-only`), and + omit the field when the answer is not clear. A wrong identifier in shipped metadata is + worse than none — the licence *file* is the authoritative artifact and ships either way. +- **`source: null` recipes** have no archive, so build.sh must stage the notice itself into + its working directory (which is the source root): + ```bash + cp "$toolchain/NOTICE" ./LICENSE # flet-libcpp-shared, flet-libomp (android) + ``` + Taking it from the same NDK the binary was copied from means the two cannot drift. + Where a recipe ships **different code per platform**, note that a source-dir file shadows + a recipe-dir file of the same name — `flet-libomp` uses exactly that to ship LLVM's notice + on Android and this repo's licence (for its own iOS stub) on iOS. + +**Watch the build log.** It prints `Bundling licence file: …`, or +`WARNING: no licence file found …` — the warning means that wheel would ship no notice. +Scope the expression to what the wheel *contains*: libiconv ships `COPYING` (GPL, for the +`iconv` program) beside `COPYING.LIB` (LGPL, for the library we actually build). + ### 3.6 — Cross-cutting conventions from the ML wave (CMake-heavy recipes) - **Version as a Jinja constant.** A bare `{% set version = "X.Y.Z" %}` as the first line, reused in `package.version` AND `source.url` — bumps become one-line edits. See `recipes/faiss-cpu/meta.yaml`; onnxruntime uses the same idiom. diff --git a/src/forge/build.py b/src/forge/build.py index 38a20a65..ebb7e463 100644 --- a/src/forge/build.py +++ b/src/forge/build.py @@ -32,7 +32,22 @@ from forge.package import Package +# Names a project uses for its licence notice. Matches the prefix rather than the whole +# name so LICENSE.txt, COPYING.LGPL, LICENCE.md and LICENSE-THIRD-PARTY all qualify. +# COPYRIGHT is included because for several projects here it IS the licence grant and +# the only such file shipped — postgresql, libxml2 and libxslt each carry their full +# permission notice in a file by that name and nothing else. NOTICE is included because +# Apache-2.0 section 4(d) requires redistributing it alongside the licence, and an +# ASF-sourced archive (arrow) keeps its attributions there rather than in LICENSE. +LICENSE_FILE_RE = re.compile(r"^(licen[cs]e|copying|copyright|notice)", re.IGNORECASE) + + class Builder(ABC): + # Whether this builder writes the wheel's METADATA itself (and so is the one that + # can act on `about`). True only for the build.sh path; a Python package's backend + # produces its own. + synthesizes_metadata = False + def __init__(self, cross_venv: CrossVEnv, package: Package): self.cross_venv = cross_venv self.package = package @@ -674,7 +689,13 @@ def read_message_file(self, filename: Path): def write_message_file(self, filename: Path, data): msg = message.Message() for key, value in data.items(): - msg[key] = value + # A list emits the header once per item: metadata fields like + # License-File are legitimately repeated, which a dict can't express. + if isinstance(value, (list, tuple)): + for item in value: + msg[key] = item + else: + msg[key] = value # I don't know whether maxheaderlen is required, but it's used by bdist_wheel. with filename.open("w", encoding="utf-8") as f: @@ -881,6 +902,16 @@ def fix_wheel(self, wheel_dir: Path): log(self.log_file, f"[{self.cross_venv}] Fixing wheel contents") + # `about` drives metadata that only the build.sh path synthesises. A Python + # package's own build backend already carries upstream's licence through, so + # setting it there would be a no-op — say so rather than ignoring it silently. + if (self.package.meta.get("about") or {}) and not self.synthesizes_metadata: + log( + self.log_file, + f"[{self.cross_venv}] WARNING: `about` is only honoured for build.sh " + "recipes; this package's build backend supplies its own metadata.", + ) + # Normalize wheel tags to forge platform tags so repacked wheels use # android_24_arm64_v8a / ios_13_0_arm64_iphoneos style platform tags. wheel_metadata_path = next(wheel_dir.glob("*.dist-info")) / "WHEEL" @@ -1057,6 +1088,8 @@ def fix_wheel(self, wheel_dir: Path): class SimplePackageBuilder(Builder): """A builder for projects that have a build.sh entry point.""" + synthesizes_metadata = True + @property def source_archive_path(self) -> Path: url = self.download_source_url() @@ -1101,6 +1134,61 @@ def prepare(self, clean=True): log(self.log_file, f"\n[{self.cross_venv}] Installing wheel-building tools") self.cross_venv.pip_install(self.log_file, ["wheel"], build=True) + def collect_license_files(self) -> list[tuple[Path, str]]: + """The licence files to ship, as (source path, path within licenses/) pairs. + + Unlike a Python package — whose build backend carries the licence through from + upstream's own metadata — a build.sh recipe's wheel is synthesised here, so + nothing brings the licence along unless we do it. Every copyleft licence in + this tree (and most permissive ones) requires the notice to accompany the + binary, so a wheel wrapping LGPL object code with no licence text is a defect. + + By default any top-level licence-shaped file in the source or the recipe + directory is picked up, which covers nearly every upstream unchanged. The + recipe directory is searched too, so a recipe can supply the notice for an + archive that ships none; a source file shadows a recipe file of the same name, + which is how a recipe can carry a fallback without overriding upstream. + + `about.license_file` (a path, or a list of them) replaces that discovery + entirely, for the two cases it cannot get right on its own: excluding a notice + that covers something the wheel does not contain, and including one that lives + below the top level. + + The relative path is preserved rather than flattened to a basename, both + because PEP 639 records License-File that way and because flattening would + silently drop one of two same-named notices (jq ships a COPYING for itself and + another for the oniguruma it bundles). + """ + about = self.package.meta.get("about") or {} + search_dirs = [self.build_path, self.package.recipe_path] + + explicit = about.get("license_file") + if explicit: + wanted = [explicit] if isinstance(explicit, str) else list(explicit) + resolved = [] + for name in wanted: + for directory in search_dirs: + candidate = directory / name + if candidate.is_file(): + resolved.append((candidate, name)) + break + else: + log( + self.log_file, + f"WARNING: about.license_file '{name}' not found in the source " + "or recipe directory.", + ) + return resolved + + found: dict[str, Path] = {} + for directory in search_dirs: + if not directory.is_dir(): + continue + for candidate in sorted(directory.iterdir()): + if candidate.is_file() and LICENSE_FILE_RE.match(candidate.name): + found.setdefault(candidate.name, candidate) + return [(path, name) for name, path in found.items()] + def make_wheel(self): build_num = str(self.package.meta["build"]["number"]) name = canonicalize_name(self.package.name) @@ -1112,6 +1200,26 @@ def make_wheel(self): log(self.log_file, f"\n[{self.cross_venv}] Writing wheel metadata") info_path.mkdir(exist_ok=True) + license_files = self.collect_license_files() + if license_files: + license_dir = info_path / "licenses" + for source, relative in license_files: + destination = license_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + log( + self.log_file, + "Bundling licence " + + ("files: " if len(license_files) > 1 else "file: ") + + ", ".join(relative for _, relative in license_files), + ) + else: + log( + self.log_file, + "WARNING: no licence file found in the source or recipe directory. " + "Set about.license_file, or add the notice to the recipe directory.", + ) + # Write the packaging metadata self.write_message_file( info_path / "WHEEL", @@ -1123,16 +1231,22 @@ def make_wheel(self): "Tag": self.wheel_tag, }, ) - self.write_message_file( - info_path / "METADATA", - { - "Metadata-Version": "1.2", - "Name": self.package.name, - "Version": self.package.version, - "Summary": "", # Compulsory according to PEP 345, - "Download-URL": "", - }, - ) + # Metadata-Version 2.4 is the floor for PEP 639's License-Expression and + # License-File; below it, a consumer or licence scanner has no way to see + # what the wheel wraps short of unpacking it. + metadata = { + "Metadata-Version": "2.4", + "Name": self.package.name, + "Version": self.package.version, + "Summary": "", # Compulsory according to PEP 345, + "Download-URL": "", + } + license_expression = (self.package.meta.get("about") or {}).get("license") + if license_expression: + metadata["License-Expression"] = license_expression + if license_files: + metadata["License-File"] = [relative for _, relative in license_files] + self.write_message_file(info_path / "METADATA", metadata) # fix wheel before packaging self.fix_wheel(self.build_path / "wheel") diff --git a/src/forge/schema/meta-schema.yaml b/src/forge/schema/meta-schema.yaml index 5db919aa..18d6e3ca 100644 --- a/src/forge/schema/meta-schema.yaml +++ b/src/forge/schema/meta-schema.yaml @@ -228,14 +228,38 @@ properties: properties: license_file: + oneOf: + - type: string + - type: array + items: + type: string + minItems: 1 + default: "" + description: >- + Path, or list of paths, relative to the source directory (or to + the recipe directory, for a notice the upstream archive doesn't + ship), to add to the wheel's .dist-info/licenses/. Any top-level + file in either directory whose name starts with "LICEN[CS]E", + "COPYING", "COPYRIGHT" or "NOTICE" (case-insensitive) is included + automatically, so most recipes need nothing here. Setting this + REPLACES that discovery, which is what you want in the two cases + it cannot get right: excluding a notice covering something the + wheel doesn't contain (libiconv's top-level COPYING is the GPL for + the `iconv` program, not the LGPL library we build), and including + one below the top level (a bundled library's own notice). Applies + to build.sh recipes: a Python package's build backend already + carries its licence through. + + license: type: string default: "" description: >- - Filename, relative to the source directory, to add to the - wheel's .dist-info directory. build-wheel will automatically - include any file in the source or recipe directory whose name - starts with "LICEN[CS]E" or "COPYING", case-insensitive. If - there is no such file, then this setting is required. + SPDX licence expression for the packaged work, e.g. "LGPL-2.1-or-later" + or "MIT OR Apache-2.0", emitted as License-Expression in the wheel + metadata so tooling can read it without unpacking the licence text. + Optional and never guessed: state it only from the upstream project's + own declaration. The licence FILE is the authoritative artifact and + ships either way. additionalProperties: false From 65f391c15ef88ee7937258fcdcd1a2c250a90799 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 21 Aug 2026 19:14:33 +0200 Subject: [PATCH 02/11] recipes: declare licences for the flet-lib* wheels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in `about` for the native-library recipes now that forge acts on it. 23 recipes get an SPDX `license`; four deliberately get none and say why in a comment, so the absence does not read as an oversight and get filled in from memory later. Every expression was read out of the archive we actually ship from, corroborated against the upstream project's own declaration, then adversarially re-checked. **A wrong identifier in shipped metadata is worse than none**, so the rule throughout was to abstain rather than guess. The licence *file* ships either way — the expression is only the machine-readable convenience on top. ## Cases where the obvious answer is wrong - **`flet-libiconv` pins `license_file: COPYING.LIB`.** Its top-level `COPYING` is **GPL-3.0**, covering the `iconv` program and docs that build.sh deletes; only `COPYING.LIB` (LGPL-2.1) applies to the library the wheel contains. Auto-discovery would have bundled both, putting GPL-3.0 text in an LGPL-only wheel — a worse outcome than shipping nothing. - **`flet-libfreetds` is `LGPL-2.0-or-later`, not 2.1.** `COPYING_LIB.txt` is the 1991 *Library* GPL. GitHub's licence API reports GPL-2.0 for that repo because licensee picks the first `COPYING*`; that is the whole-repo answer, and it is wrong for this wheel — the plain-GPL notices cover `src/pool` and `src/apps`, which the configure flags exclude. - **`flet-libpng` is lowercase `libpng-2.0`.** Capital-L `Libpng` is the version-1 licence and applies only up to 1.6.35; the pinned 1.6.43 carries the 2.0 text, and the archive's own CMakeLists has the matching SPDX tag. - **`flet-libfreetype` keeps `LICENSE.TXT` first**, ahead of both arm texts: it is the file that explains the dual licence. The parentheses in `(FTL OR GPL-2.0-or-later) AND MIT` are load-bearing — SPDX binds AND tighter than OR. - Several expressions are compound because vendored code is genuinely in the binary: jq bundles oniguruma, libpsl compiles the MPL-2.0 PSL in and links Chromium's BSD-3 DAFSA sources, liboprf installs Apache-2.0 karamel headers, libtiff's `tif_lzw.c` carries a second Berkeley grant that 4.7.0's `LICENSE.md` omits (upstream added it afterwards). ## Abstained `flet-libgeos` (headers name no LGPL version, so section 13 makes any FSF version choosable — broader than `-or-later`, and not `-only`), `flet-libgdal` (core MIT, but the cross build forces internal libtiff/libgeotiff/libjpeg/zlib/json-c/qhull/LercLib that LICENSE.TXT does not enumerate), `flet-libxslt` (Expat plus a name-in-advertising clause, so neither `MIT` nor `X11` is both faithful and conventional, and upstream declares nothing), and `flet-libomp` (ships different code per platform — LLVM's `libomp.so` on Android, this repo's own serial stub on iOS — so no single expression is honest, and `AND` would be wrong too since no wheel contains both). ## Also here `flet-libcpp-shared` and `flet-libomp` have no upstream archive, so their build.sh now stages a notice into the source root. Both take it from the same NDK the binary is copied from, so the two cannot drift. `flet-libomp` additionally carries a recipe-level `LICENSE` covering its iOS stub; the Android leg's staged NDK notice shadows it by name, which is how one recipe ships the right notice on each platform. --- recipes/flet-libarrow/meta.yaml | 3 +++ recipes/flet-libcpp-shared/build.sh | 5 +++++ recipes/flet-libcpp-shared/meta.yaml | 3 +++ recipes/flet-libcrc32c/meta.yaml | 5 ++++- recipes/flet-libcurl/meta.yaml | 3 +++ recipes/flet-libfreetds/meta.yaml | 6 ++++++ recipes/flet-libfreetype/meta.yaml | 9 ++++++++- recipes/flet-libgdal/meta.yaml | 5 +++++ recipes/flet-libgeos/meta.yaml | 5 +++++ recipes/flet-libiconv/meta.yaml | 7 +++++++ recipes/flet-libjpeg/meta.yaml | 6 +++++- recipes/flet-libjq/meta.yaml | 9 ++++++++- recipes/flet-libmagic/meta.yaml | 3 +++ recipes/flet-libomp/LICENSE | 27 +++++++++++++++++++++++++++ recipes/flet-libomp/build.sh | 7 +++++++ recipes/flet-libomp/meta.yaml | 6 ++++++ recipes/flet-libopaque/meta.yaml | 5 ++++- recipes/flet-libopenblas/meta.yaml | 7 +++++++ recipes/flet-liboprf/meta.yaml | 6 +++++- recipes/flet-libpng/meta.yaml | 6 +++++- recipes/flet-libpq/meta.yaml | 3 +++ recipes/flet-libproj/meta.yaml | 5 ++++- recipes/flet-libpsl/meta.yaml | 10 +++++++++- recipes/flet-libpyjni/meta.yaml | 5 ++++- recipes/flet-libsodium/meta.yaml | 5 ++++- recipes/flet-libtiff/meta.yaml | 7 ++++++- recipes/flet-libxml2/meta.yaml | 4 ++++ recipes/flet-libxslt/meta.yaml | 5 +++++ recipes/flet-libyaml/meta.yaml | 3 +++ recipes/flet-libzbar/meta.yaml | 3 +++ 30 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 recipes/flet-libomp/LICENSE diff --git a/recipes/flet-libarrow/meta.yaml b/recipes/flet-libarrow/meta.yaml index 443a6a62..8720ca6e 100644 --- a/recipes/flet-libarrow/meta.yaml +++ b/recipes/flet-libarrow/meta.yaml @@ -26,3 +26,6 @@ requirements: # libarrow.so is C++; on Android it links libc++_shared.so. - flet-libcpp-shared >=27.2.12479018 # {% endif %} + +about: + license: Apache-2.0 diff --git a/recipes/flet-libcpp-shared/build.sh b/recipes/flet-libcpp-shared/build.sh index 3ffec39c..62e62404 100755 --- a/recipes/flet-libcpp-shared/build.sh +++ b/recipes/flet-libcpp-shared/build.sh @@ -11,3 +11,8 @@ export LIBC_SHARED_SO="$toolchain/sysroot/usr/lib/${HOST_TRIPLET}/libc++_shared. mkdir -p $PREFIX/lib cp $LIBC_SHARED_SO $PREFIX/lib + +# This recipe has no upstream archive, so nothing carries a licence into the wheel on +# its own. libc++_shared.so is LLVM's, under Apache-2.0 WITH LLVM-exception; take the +# notice from the same NDK the .so was copied from so the two can never drift. +cp "$toolchain/NOTICE" ./LICENSE diff --git a/recipes/flet-libcpp-shared/meta.yaml b/recipes/flet-libcpp-shared/meta.yaml index 4bb39d42..591551c2 100644 --- a/recipes/flet-libcpp-shared/meta.yaml +++ b/recipes/flet-libcpp-shared/meta.yaml @@ -11,3 +11,6 @@ build: # No upstream archive: build.sh copies libc++_shared.so out of the NDK toolchain. source: null + +about: + license: Apache-2.0 WITH LLVM-exception diff --git a/recipes/flet-libcrc32c/meta.yaml b/recipes/flet-libcrc32c/meta.yaml index dd5bb7b5..5efb7dff 100644 --- a/recipes/flet-libcrc32c/meta.yaml +++ b/recipes/flet-libcrc32c/meta.yaml @@ -12,4 +12,7 @@ source: requirements: build: - - cmake \ No newline at end of file + - cmake + +about: + license: BSD-3-Clause diff --git a/recipes/flet-libcurl/meta.yaml b/recipes/flet-libcurl/meta.yaml index 2a331953..bc1b1b98 100644 --- a/recipes/flet-libcurl/meta.yaml +++ b/recipes/flet-libcurl/meta.yaml @@ -19,3 +19,6 @@ requirements: patches: - config.patch + +about: + license: curl diff --git a/recipes/flet-libfreetds/meta.yaml b/recipes/flet-libfreetds/meta.yaml index a341f82a..fb659490 100644 --- a/recipes/flet-libfreetds/meta.yaml +++ b/recipes/flet-libfreetds/meta.yaml @@ -18,3 +18,9 @@ build: requirements: host: - openssl ^3.0.12 + +about: + # COPYING_LIB.txt is the 1991 *Library* GPL v2, not 2.1. The plain-GPL + # notices cover src/pool and src/apps, which --disable-apps/server/pool + # excludes -- so GitHub's whole-repo answer of GPL-2.0 is wrong for this wheel. + license: LGPL-2.0-or-later diff --git a/recipes/flet-libfreetype/meta.yaml b/recipes/flet-libfreetype/meta.yaml index c78a9018..83d170e3 100644 --- a/recipes/flet-libfreetype/meta.yaml +++ b/recipes/flet-libfreetype/meta.yaml @@ -14,4 +14,11 @@ patches: - config.patch about: - license_file: docs/FTL.TXT + # LICENSE.TXT states the dual arm and is what a reader needs first; both + # arm texts ship beside it. MIT covers the bdf/pcf/fthash code compiled in. + # Parentheses are load-bearing: SPDX binds AND tighter than OR. + license_file: + - LICENSE.TXT + - docs/FTL.TXT + - docs/GPLv2.TXT + license: (FTL OR GPL-2.0-or-later) AND MIT diff --git a/recipes/flet-libgdal/meta.yaml b/recipes/flet-libgdal/meta.yaml index cabe470e..d0e8ea41 100644 --- a/recipes/flet-libgdal/meta.yaml +++ b/recipes/flet-libgdal/meta.yaml @@ -1,3 +1,8 @@ +# No `about.license`, deliberately: the core is MIT but the wheel is not MIT-only. +# GDAL_BUILD_OPTIONAL_DRIVERS=OFF does not disable GTiff, and the cross build +# forces internal libtiff/libgeotiff/libjpeg/zlib/json-c/qhull/LercLib, which +# LICENSE.TXT does not enumerate. Compose it from a real build's CMake configure +# summary before setting one. LICENSE.TXT ships either way. {% set version = "3.13.1" %} package: diff --git a/recipes/flet-libgeos/meta.yaml b/recipes/flet-libgeos/meta.yaml index 3b59c5f1..7201cc1d 100644 --- a/recipes/flet-libgeos/meta.yaml +++ b/recipes/flet-libgeos/meta.yaml @@ -1,3 +1,8 @@ +# No `about.license`, deliberately: the source headers name no LGPL version +# ("See the COPYING file"), so LGPL-2.1 section 13 lets a recipient choose any +# FSF version -- broader than -or-later, and positively not -only. Neither +# standard identifier is faithful, and src/deps/ryu (Apache-2.0 OR BSL-1.0) is +# linked in besides. The COPYING file ships either way. {% set version = "3.13.1" %} package: diff --git a/recipes/flet-libiconv/meta.yaml b/recipes/flet-libiconv/meta.yaml index 26b4b588..629be058 100644 --- a/recipes/flet-libiconv/meta.yaml +++ b/recipes/flet-libiconv/meta.yaml @@ -14,3 +14,10 @@ build: source: url: https://ftp.gnu.org/gnu/libiconv/libiconv-{{ version }}.tar.gz + +about: + # Pinned deliberately: the top-level COPYING is GPL-3.0 covering the `iconv` + # program and docs, which build.sh deletes. Only COPYING.LIB applies to the + # library this wheel ships, and auto-discovery would bundle both. + license_file: COPYING.LIB + license: LGPL-2.1-or-later diff --git a/recipes/flet-libjpeg/meta.yaml b/recipes/flet-libjpeg/meta.yaml index be44b418..08a2ce51 100644 --- a/recipes/flet-libjpeg/meta.yaml +++ b/recipes/flet-libjpeg/meta.yaml @@ -12,4 +12,8 @@ build: requirements: build: - - cmake \ No newline at end of file + - cmake + +about: + # TurboJPEG is BSD-3-Clause and the SIMD sources are zlib-licensed; both build. + license: IJG AND BSD-3-Clause AND Zlib diff --git a/recipes/flet-libjq/meta.yaml b/recipes/flet-libjq/meta.yaml index b3c3b4ca..6782343b 100644 --- a/recipes/flet-libjq/meta.yaml +++ b/recipes/flet-libjq/meta.yaml @@ -15,4 +15,11 @@ requirements: - cmake patches: - - config.patch \ No newline at end of file + - config.patch + +about: + # oniguruma is bundled (--with-oniguruma=builtin), so its notice ships too. + license_file: + - COPYING + - modules/oniguruma/COPYING + license: MIT AND BSD-2-Clause AND ICU AND dtoa diff --git a/recipes/flet-libmagic/meta.yaml b/recipes/flet-libmagic/meta.yaml index 572cc50e..690560b0 100644 --- a/recipes/flet-libmagic/meta.yaml +++ b/recipes/flet-libmagic/meta.yaml @@ -11,3 +11,6 @@ source: # The `file` project ships a release tarball with a pre-generated ./configure # (autotools), so no autoreconf is needed (unlike flet-libzbar). url: https://astron.com/pub/file/file-{{ version }}.tar.gz + +about: + license: BSD-2-Clause-Darwin AND BSD-2-Clause diff --git a/recipes/flet-libomp/LICENSE b/recipes/flet-libomp/LICENSE new file mode 100644 index 00000000..ea5c608f --- /dev/null +++ b/recipes/flet-libomp/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2023 Russell Keith-Magee. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of Mobile Forge nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/recipes/flet-libomp/build.sh b/recipes/flet-libomp/build.sh index 6cbfd0a7..2c16e136 100755 --- a/recipes/flet-libomp/build.sh +++ b/recipes/flet-libomp/build.sh @@ -26,6 +26,13 @@ android) mkdir -p "$PREFIX/lib" cp "$libomp" "$PREFIX/lib/libomp.so" + + # No upstream archive, so nothing carries a licence into the wheel on its own. + # This is LLVM's runtime, under Apache-2.0 WITH LLVM-exception; take the notice + # from the same NDK the .so was copied from so the two can never drift. Named + # LICENSE so it shadows the recipe's own copy, which covers only the iOS stub + # below — the two platforms ship different code under different licences. + cp "$toolchain/NOTICE" ./LICENSE ;; iphoneos|iphonesimulator) diff --git a/recipes/flet-libomp/meta.yaml b/recipes/flet-libomp/meta.yaml index 17f1738f..11572148 100644 --- a/recipes/flet-libomp/meta.yaml +++ b/recipes/flet-libomp/meta.yaml @@ -1,3 +1,9 @@ +# No `about.license`, deliberately: this recipe ships different code per platform, +# so no single expression is honest. Android copies LLVM's libomp.so +# (Apache-2.0 WITH LLVM-exception); iOS compiles the serial stub below, which is +# this repo's own code (BSD-3-Clause). AND would be wrong too -- no wheel contains +# both. The correct notice ships on each platform: build.sh stages the NDK's on +# Android, and the recipe's own LICENSE covers the stub on iOS. # libomp is the LLVM OpenMP runtime. # # ANDROID: the real, multi-threaded libomp.so is extracted from the NDK's clang diff --git a/recipes/flet-libopaque/meta.yaml b/recipes/flet-libopaque/meta.yaml index 19c1492b..755dbe51 100644 --- a/recipes/flet-libopaque/meta.yaml +++ b/recipes/flet-libopaque/meta.yaml @@ -16,4 +16,7 @@ requirements: - flet-liboprf 0.5.0 patches: - - mobile.patch \ No newline at end of file + - mobile.patch + +about: + license: LGPL-3.0-or-later diff --git a/recipes/flet-libopenblas/meta.yaml b/recipes/flet-libopenblas/meta.yaml index 5de5da9b..bc7ac776 100644 --- a/recipes/flet-libopenblas/meta.yaml +++ b/recipes/flet-libopenblas/meta.yaml @@ -9,3 +9,10 @@ build: source: url: https://github.com/OpenMathLib/OpenBLAS/releases/download/v{{ version }}/OpenBLAS-{{ version }}.tar.gz + +about: + # The GotoBLAS kernels carry their own notice, which LICENSE does not restate. + license_file: + - LICENSE + - GotoBLAS_00License.txt + license: BSD-3-Clause diff --git a/recipes/flet-liboprf/meta.yaml b/recipes/flet-liboprf/meta.yaml index 54c9034c..e8b5fc44 100644 --- a/recipes/flet-liboprf/meta.yaml +++ b/recipes/flet-liboprf/meta.yaml @@ -17,4 +17,8 @@ build: CFLAGS: '-Qunused-arguments -Wno-unreachable-code' patches: - - mobile.patch \ No newline at end of file + - mobile.patch + +about: + # The karamel noiseXK headers are Apache-2.0 and are installed verbatim. + license: LGPL-3.0-or-later AND Apache-2.0 diff --git a/recipes/flet-libpng/meta.yaml b/recipes/flet-libpng/meta.yaml index c78dd14a..24c4279e 100644 --- a/recipes/flet-libpng/meta.yaml +++ b/recipes/flet-libpng/meta.yaml @@ -11,4 +11,8 @@ source: url: https://github.com/pnggroup/libpng/archive/refs/tags/v{{ version }}.tar.gz patches: - - config.patch \ No newline at end of file + - config.patch + +about: + # Lowercase libpng-2.0: the capital-L `Libpng` licence applies only up to 1.6.35. + license: libpng-2.0 diff --git a/recipes/flet-libpq/meta.yaml b/recipes/flet-libpq/meta.yaml index 530bac33..7258e8fa 100644 --- a/recipes/flet-libpq/meta.yaml +++ b/recipes/flet-libpq/meta.yaml @@ -19,3 +19,6 @@ build: requirements: host: - openssl ^3.0.12 + +about: + license: PostgreSQL diff --git a/recipes/flet-libproj/meta.yaml b/recipes/flet-libproj/meta.yaml index c3a0936b..bb38e0d7 100644 --- a/recipes/flet-libproj/meta.yaml +++ b/recipes/flet-libproj/meta.yaml @@ -19,4 +19,7 @@ requirements: - cmake host: - flet-libtiff 4.7.0 - - flet-libcurl 8.11.0 \ No newline at end of file + - flet-libcurl 8.11.0 + +about: + license: MIT diff --git a/recipes/flet-libpsl/meta.yaml b/recipes/flet-libpsl/meta.yaml index 37c2f732..4e11d69d 100644 --- a/recipes/flet-libpsl/meta.yaml +++ b/recipes/flet-libpsl/meta.yaml @@ -11,4 +11,12 @@ build: number: 10 patches: - - config.patch \ No newline at end of file + - config.patch + +about: + # The PSL data is compiled in (builtin is on), which is the MPL-2.0 part, and + # the Chromium DAFSA sources are BSD-3-Clause with their own notice. + license_file: + - COPYING + - src/LICENSE.chromium + license: MIT AND BSD-3-Clause AND MPL-2.0 diff --git a/recipes/flet-libpyjni/meta.yaml b/recipes/flet-libpyjni/meta.yaml index f415b53e..afd4dd56 100644 --- a/recipes/flet-libpyjni/meta.yaml +++ b/recipes/flet-libpyjni/meta.yaml @@ -15,4 +15,7 @@ source: requirements: build: - - cmake \ No newline at end of file + - cmake + +about: + license: MIT diff --git a/recipes/flet-libsodium/meta.yaml b/recipes/flet-libsodium/meta.yaml index b59b9f45..aeff15d1 100644 --- a/recipes/flet-libsodium/meta.yaml +++ b/recipes/flet-libsodium/meta.yaml @@ -8,4 +8,7 @@ build: number: 10 source: - url: https://github.com/jedisct1/libsodium/releases/download/{{ version }}-RELEASE/libsodium-{{ version }}.tar.gz \ No newline at end of file + url: https://github.com/jedisct1/libsodium/releases/download/{{ version }}-RELEASE/libsodium-{{ version }}.tar.gz + +about: + license: ISC diff --git a/recipes/flet-libtiff/meta.yaml b/recipes/flet-libtiff/meta.yaml index 510b1175..3a38e33b 100644 --- a/recipes/flet-libtiff/meta.yaml +++ b/recipes/flet-libtiff/meta.yaml @@ -15,4 +15,9 @@ requirements: - flet-libjpeg 3.0.90 patches: - - config.patch \ No newline at end of file + - config.patch + +about: + # tif_lzw.c carries a second Berkeley grant that LICENSE.md omits at 4.7.0; + # upstream added that section afterwards, so 4.7.1+ ships the full text. + license: libtiff AND BSD-4.3TAHOE diff --git a/recipes/flet-libxml2/meta.yaml b/recipes/flet-libxml2/meta.yaml index 782425f2..20267e6e 100755 --- a/recipes/flet-libxml2/meta.yaml +++ b/recipes/flet-libxml2/meta.yaml @@ -17,3 +17,7 @@ source: patches: - {{ patch }} + +about: + # The Copyright file excepts dict.c and list.c, both of which are always built. + license: MIT AND ISC-Veillard diff --git a/recipes/flet-libxslt/meta.yaml b/recipes/flet-libxslt/meta.yaml index eb1a9bad..ec9f9b24 100755 --- a/recipes/flet-libxslt/meta.yaml +++ b/recipes/flet-libxslt/meta.yaml @@ -1,3 +1,8 @@ +# No `about.license`, deliberately: both Copyright blocks are Expat plus a +# name-in-advertising clause, so not verbatim MIT; X11 is the exact structural +# match but matches no downstream convention, and upstream makes no declaration +# of its own. MIT vs X11 is a policy call, not a reading. The Copyright file +# ships either way. # {% set version = "1.1.45" %} # {% if version == "1.1.32" %} # {% set libxml2_version = "2.9.8" %} diff --git a/recipes/flet-libyaml/meta.yaml b/recipes/flet-libyaml/meta.yaml index 3a1909ab..7adfdc0e 100644 --- a/recipes/flet-libyaml/meta.yaml +++ b/recipes/flet-libyaml/meta.yaml @@ -9,3 +9,6 @@ build: source: url: https://github.com/yaml/libyaml/releases/download/{{ version }}/yaml-{{ version }}.tar.gz + +about: + license: MIT diff --git a/recipes/flet-libzbar/meta.yaml b/recipes/flet-libzbar/meta.yaml index 85755fe5..d4499ee0 100644 --- a/recipes/flet-libzbar/meta.yaml +++ b/recipes/flet-libzbar/meta.yaml @@ -29,3 +29,6 @@ requirements: # no iconv at API 24, so provide the static GNU libiconv to compile + link. - flet-libiconv 1.17 # {% endif %} + +about: + license: LGPL-2.1-or-later From 359220ad758adc8edb7805245cad91d642f1aeb2 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 21 Aug 2026 20:05:29 +0200 Subject: [PATCH 03/11] recipes: give flet-libomp a per-SDK licence instead of none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `about` is inside the Jinja-rendered meta.yaml like every other key, so a recipe that ships different code per platform can just gate the licence on `sdk` — which is the right answer here rather than leaving it unset: android -> Apache-2.0 WITH LLVM-exception (LLVM's libomp.so, from the NDK) ios -> BSD-3-Clause (the serial stub build.sh generates, which is this repo's own code) Verified on real builds of both platforms: the wheels now carry those two expressions, alongside the notices that already split correctly (130424 B of LLVM NOTICE on Android, 1528 B of this repo's LICENSE on iOS). The other three abstentions stand — theirs are not per-platform ambiguities. libgeos is about which LGPL version a recipient may choose, libgdal about which internal drivers the cross build forces in, libxslt about MIT vs X11 for an Expat-plus-advertising-clause text; Jinja cannot resolve any of those. --- .claude/skills/new-mobile-recipe/SKILL.md | 22 +++++++++++++++++++--- recipes/flet-libomp/meta.yaml | 17 +++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.claude/skills/new-mobile-recipe/SKILL.md b/.claude/skills/new-mobile-recipe/SKILL.md index 85a63bef..364271b3 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -297,9 +297,25 @@ Three cases need a line in `meta.yaml`: cp "$toolchain/NOTICE" ./LICENSE # flet-libcpp-shared, flet-libomp (android) ``` Taking it from the same NDK the binary was copied from means the two cannot drift. - Where a recipe ships **different code per platform**, note that a source-dir file shadows - a recipe-dir file of the same name — `flet-libomp` uses exactly that to ship LLVM's notice - on Android and this repo's licence (for its own iOS stub) on iOS. + +**A recipe that ships different code per platform gets a different licence per platform.** +`about` is inside the Jinja-rendered meta.yaml like everything else, so gate it on `sdk` +rather than giving up and leaving it unset: + +```yaml +about: +# {% if sdk == 'android' %} + license: Apache-2.0 WITH LLVM-exception +# {% else %} + license: BSD-3-Clause +# {% endif %} +``` + +That is `flet-libomp`: Android carries LLVM's `libomp.so` from the NDK, iOS carries the +serial stub build.sh generates, which is this repo's own code. `AND` would be wrong — no +single wheel contains both. The *file* side splits the same way for free, because a +source-dir file shadows a recipe-dir file of the same name: build.sh stages the NDK notice +as `LICENSE` on Android, and on iOS the recipe's own `LICENSE` is what remains. **Watch the build log.** It prints `Bundling licence file: …`, or `WARNING: no licence file found …` — the warning means that wheel would ship no notice. diff --git a/recipes/flet-libomp/meta.yaml b/recipes/flet-libomp/meta.yaml index 11572148..57682b8a 100644 --- a/recipes/flet-libomp/meta.yaml +++ b/recipes/flet-libomp/meta.yaml @@ -1,9 +1,3 @@ -# No `about.license`, deliberately: this recipe ships different code per platform, -# so no single expression is honest. Android copies LLVM's libomp.so -# (Apache-2.0 WITH LLVM-exception); iOS compiles the serial stub below, which is -# this repo's own code (BSD-3-Clause). AND would be wrong too -- no wheel contains -# both. The correct notice ships on each platform: build.sh stages the NDK's on -# Android, and the recipe's own LICENSE covers the stub on iOS. # libomp is the LLVM OpenMP runtime. # # ANDROID: the real, multi-threaded libomp.so is extracted from the NDK's clang @@ -30,3 +24,14 @@ build: # No upstream archive: build.sh produces its own libomp — copied from the NDK on # Android, generated as a serial stub on iOS. source: null + +about: + # The two platforms ship different code, so the licence is per-SDK rather than one + # expression: Android carries LLVM's libomp.so out of the NDK, while iOS carries the + # serial stub build.sh generates, which is this repo's own code. `AND` would be wrong + # — no single wheel contains both. +# {% if sdk == 'android' %} + license: Apache-2.0 WITH LLVM-exception +# {% else %} + license: BSD-3-Clause +# {% endif %} From fdca0bcd7999025f9349c5dcc6934a771de78388 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 23 Aug 2026 00:57:05 +0200 Subject: [PATCH 04/11] recipes: bump build numbers so the licences actually publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this the whole branch is a no-op in production. `publish_to_pypi` is 409-tolerant: a wheel already published at `-` is skipped, so re-publishing these recipes unchanged would leave pypi.flet.dev serving the same licence-less wheels it serves today. 23 of the 27 native-library recipes sit at exactly their published build number — checked against the index rather than assumed: flet-libarrow 24.0.0 recipe 1 == published 1 flet-libiconv 1.17 recipe 1 == published 1 flet-libxml2 2.15.3 recipe 10 == published 10 ... Each is bumped to `published + 1`. The other four — `flet-libcurl`, `flet-liboprf`, `flet-libproj`, `flet-libtiff` — have nothing published at their current version, so their existing build number ships correctly and is left alone. Nothing but `number:` lines changed; the bump was derived from the index, so re-running it is a no-op rather than a second increment. Consequence worth stating: this forces every consumer to re-resolve these libraries on their next build. That is the point — it is how the licence-carrying wheels replace the ones currently being served. --- recipes/flet-libarrow/meta.yaml | 2 +- recipes/flet-libcpp-shared/meta.yaml | 2 +- recipes/flet-libcrc32c/meta.yaml | 2 +- recipes/flet-libfreetds/meta.yaml | 2 +- recipes/flet-libfreetype/meta.yaml | 2 +- recipes/flet-libgdal/meta.yaml | 2 +- recipes/flet-libgeos/meta.yaml | 2 +- recipes/flet-libiconv/meta.yaml | 2 +- recipes/flet-libjpeg/meta.yaml | 2 +- recipes/flet-libjq/meta.yaml | 2 +- recipes/flet-libmagic/meta.yaml | 2 +- recipes/flet-libomp/meta.yaml | 2 +- recipes/flet-libopaque/meta.yaml | 2 +- recipes/flet-libopenblas/meta.yaml | 2 +- recipes/flet-libpng/meta.yaml | 2 +- recipes/flet-libpq/meta.yaml | 2 +- recipes/flet-libpsl/meta.yaml | 2 +- recipes/flet-libpyjni/meta.yaml | 2 +- recipes/flet-libsodium/meta.yaml | 2 +- recipes/flet-libxml2/meta.yaml | 2 +- recipes/flet-libxslt/meta.yaml | 2 +- recipes/flet-libyaml/meta.yaml | 2 +- recipes/flet-libzbar/meta.yaml | 2 +- 23 files changed, 23 insertions(+), 23 deletions(-) diff --git a/recipes/flet-libarrow/meta.yaml b/recipes/flet-libarrow/meta.yaml index 8720ca6e..7a4b3399 100644 --- a/recipes/flet-libarrow/meta.yaml +++ b/recipes/flet-libarrow/meta.yaml @@ -9,7 +9,7 @@ package: # not worth diverging from the flet-lib* convention to skip it. build: - number: 1 + number: 2 source: # Apache Arrow C++ (curated source release; build dir = its cpp/). pyarrow diff --git a/recipes/flet-libcpp-shared/meta.yaml b/recipes/flet-libcpp-shared/meta.yaml index 591551c2..f9d7fdc3 100644 --- a/recipes/flet-libcpp-shared/meta.yaml +++ b/recipes/flet-libcpp-shared/meta.yaml @@ -7,7 +7,7 @@ package: platforms: [android] build: - number: 10 + number: 11 # No upstream archive: build.sh copies libc++_shared.so out of the NDK toolchain. source: null diff --git a/recipes/flet-libcrc32c/meta.yaml b/recipes/flet-libcrc32c/meta.yaml index 5efb7dff..b7e63241 100644 --- a/recipes/flet-libcrc32c/meta.yaml +++ b/recipes/flet-libcrc32c/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://github.com/google/crc32c/archive/refs/tags/{{ version }}.tar.gz diff --git a/recipes/flet-libfreetds/meta.yaml b/recipes/flet-libfreetds/meta.yaml index fb659490..b9a71022 100644 --- a/recipes/flet-libfreetds/meta.yaml +++ b/recipes/flet-libfreetds/meta.yaml @@ -9,7 +9,7 @@ source: url: https://www.freetds.org/files/stable/freetds-{{ version }}.tar.gz build: - number: 1 + number: 2 script_env: # OpenSSL (for TLS) comes from the python-build support tree, surfaced by the # `openssl` host requirement at {platlib}/opt (same as cryptography). diff --git a/recipes/flet-libfreetype/meta.yaml b/recipes/flet-libfreetype/meta.yaml index 83d170e3..b83b628b 100644 --- a/recipes/flet-libfreetype/meta.yaml +++ b/recipes/flet-libfreetype/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://downloads.sourceforge.net/project/freetype/freetype2/{{ version }}/freetype-{{ version }}.tar.gz diff --git a/recipes/flet-libgdal/meta.yaml b/recipes/flet-libgdal/meta.yaml index d0e8ea41..b02b7309 100644 --- a/recipes/flet-libgdal/meta.yaml +++ b/recipes/flet-libgdal/meta.yaml @@ -13,7 +13,7 @@ source: url: https://github.com/OSGeo/gdal/releases/download/v{{ version }}/gdal-{{ version }}.tar.gz build: - number: 1 + number: 2 requirements: build: diff --git a/recipes/flet-libgeos/meta.yaml b/recipes/flet-libgeos/meta.yaml index 7201cc1d..74ba0705 100644 --- a/recipes/flet-libgeos/meta.yaml +++ b/recipes/flet-libgeos/meta.yaml @@ -10,7 +10,7 @@ package: version: '{{ version }}' build: - number: 1 + number: 2 source: url: http://download.osgeo.org/geos/geos-{{ version }}.tar.bz2 diff --git a/recipes/flet-libiconv/meta.yaml b/recipes/flet-libiconv/meta.yaml index 629be058..2db6b74c 100644 --- a/recipes/flet-libiconv/meta.yaml +++ b/recipes/flet-libiconv/meta.yaml @@ -10,7 +10,7 @@ package: platforms: [android] build: - number: 1 + number: 2 source: url: https://ftp.gnu.org/gnu/libiconv/libiconv-{{ version }}.tar.gz diff --git a/recipes/flet-libjpeg/meta.yaml b/recipes/flet-libjpeg/meta.yaml index 08a2ce51..6918e805 100644 --- a/recipes/flet-libjpeg/meta.yaml +++ b/recipes/flet-libjpeg/meta.yaml @@ -8,7 +8,7 @@ source: url: https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/{{ version }}/libjpeg-turbo-{{ version }}.tar.gz build: - number: 10 + number: 11 requirements: build: diff --git a/recipes/flet-libjq/meta.yaml b/recipes/flet-libjq/meta.yaml index 6782343b..5a813eea 100644 --- a/recipes/flet-libjq/meta.yaml +++ b/recipes/flet-libjq/meta.yaml @@ -8,7 +8,7 @@ source: url: https://github.com/jqlang/jq/releases/download/jq-{{ version }}/jq-{{ version }}.tar.gz build: - number: 11 + number: 12 requirements: build: diff --git a/recipes/flet-libmagic/meta.yaml b/recipes/flet-libmagic/meta.yaml index 690560b0..86cfeaba 100644 --- a/recipes/flet-libmagic/meta.yaml +++ b/recipes/flet-libmagic/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 1 + number: 2 source: # The `file` project ships a release tarball with a pre-generated ./configure diff --git a/recipes/flet-libomp/meta.yaml b/recipes/flet-libomp/meta.yaml index 57682b8a..302d63ff 100644 --- a/recipes/flet-libomp/meta.yaml +++ b/recipes/flet-libomp/meta.yaml @@ -19,7 +19,7 @@ package: version: "27.3.13750724" build: - number: 2 + number: 3 # No upstream archive: build.sh produces its own libomp — copied from the NDK on # Android, generated as a serial stub on iOS. diff --git a/recipes/flet-libopaque/meta.yaml b/recipes/flet-libopaque/meta.yaml index 755dbe51..1be2cf8c 100644 --- a/recipes/flet-libopaque/meta.yaml +++ b/recipes/flet-libopaque/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 1 + number: 2 source: url: https://github.com/stef/libopaque/archive/refs/tags/v{{ version }}.tar.gz diff --git a/recipes/flet-libopenblas/meta.yaml b/recipes/flet-libopenblas/meta.yaml index bc7ac776..10978ac2 100644 --- a/recipes/flet-libopenblas/meta.yaml +++ b/recipes/flet-libopenblas/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 0 + number: 1 source: url: https://github.com/OpenMathLib/OpenBLAS/releases/download/v{{ version }}/OpenBLAS-{{ version }}.tar.gz diff --git a/recipes/flet-libpng/meta.yaml b/recipes/flet-libpng/meta.yaml index 24c4279e..a43e4cdc 100644 --- a/recipes/flet-libpng/meta.yaml +++ b/recipes/flet-libpng/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://github.com/pnggroup/libpng/archive/refs/tags/v{{ version }}.tar.gz diff --git a/recipes/flet-libpq/meta.yaml b/recipes/flet-libpq/meta.yaml index 7258e8fa..791dfe54 100644 --- a/recipes/flet-libpq/meta.yaml +++ b/recipes/flet-libpq/meta.yaml @@ -10,7 +10,7 @@ source: url: https://ftp.postgresql.org/pub/source/v{{ version }}/postgresql-{{ version }}.tar.gz build: - number: 1 + number: 2 script_env: # OpenSSL (libpq TLS) comes from the python-build support tree, surfaced by # the `openssl` host requirement at {platlib}/opt (same as cryptography). diff --git a/recipes/flet-libpsl/meta.yaml b/recipes/flet-libpsl/meta.yaml index 4e11d69d..a78b29f1 100644 --- a/recipes/flet-libpsl/meta.yaml +++ b/recipes/flet-libpsl/meta.yaml @@ -8,7 +8,7 @@ source: url: https://github.com/rockdaboot/libpsl/releases/download/{{ version }}/libpsl-{{ version }}.tar.gz build: - number: 10 + number: 11 patches: - config.patch diff --git a/recipes/flet-libpyjni/meta.yaml b/recipes/flet-libpyjni/meta.yaml index afd4dd56..1de48acb 100644 --- a/recipes/flet-libpyjni/meta.yaml +++ b/recipes/flet-libpyjni/meta.yaml @@ -8,7 +8,7 @@ package: platforms: [android] build: - number: 10 + number: 11 source: url: https://github.com/flet-dev/libpyjni/releases/download/v{{ version }}/pyjni-{{ version }}.tar.gz diff --git a/recipes/flet-libsodium/meta.yaml b/recipes/flet-libsodium/meta.yaml index aeff15d1..50bc4ab2 100644 --- a/recipes/flet-libsodium/meta.yaml +++ b/recipes/flet-libsodium/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://github.com/jedisct1/libsodium/releases/download/{{ version }}-RELEASE/libsodium-{{ version }}.tar.gz diff --git a/recipes/flet-libxml2/meta.yaml b/recipes/flet-libxml2/meta.yaml index 20267e6e..6583381d 100755 --- a/recipes/flet-libxml2/meta.yaml +++ b/recipes/flet-libxml2/meta.yaml @@ -10,7 +10,7 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://download.gnome.org/sources/libxml2/{{ version.rsplit('.', 1)[0] }}/libxml2-{{ version }}.tar.xz diff --git a/recipes/flet-libxslt/meta.yaml b/recipes/flet-libxslt/meta.yaml index ec9f9b24..20e756df 100755 --- a/recipes/flet-libxslt/meta.yaml +++ b/recipes/flet-libxslt/meta.yaml @@ -17,7 +17,7 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://download.gnome.org/sources/libxslt/{{ version.rsplit('.', 1)[0] }}/libxslt-{{ version }}.tar.xz diff --git a/recipes/flet-libyaml/meta.yaml b/recipes/flet-libyaml/meta.yaml index 7adfdc0e..72d2857c 100644 --- a/recipes/flet-libyaml/meta.yaml +++ b/recipes/flet-libyaml/meta.yaml @@ -5,7 +5,7 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://github.com/yaml/libyaml/releases/download/{{ version }}/yaml-{{ version }}.tar.gz diff --git a/recipes/flet-libzbar/meta.yaml b/recipes/flet-libzbar/meta.yaml index d4499ee0..64525d73 100644 --- a/recipes/flet-libzbar/meta.yaml +++ b/recipes/flet-libzbar/meta.yaml @@ -11,7 +11,7 @@ source: url: https://github.com/mchehab/zbar/archive/refs/tags/{{ version }}.tar.gz build: - number: 1 + number: 2 # zbar's GitHub archive ships only configure.ac, so build.sh runs autoreconf — # which needs autotools + gettext/autopoint on the build host. before_all: From 193f898189135f6f1590fa30ff9055e571f74f18 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 23 Aug 2026 01:01:09 +0200 Subject: [PATCH 05/11] recipe-tester: pin the recipe under test to the wheel that was built [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS "pillow reports codecs no forge wheel has" mystery was not a pillow defect, not an iOS packaging quirk, and not the recipe-tester running fake code. The test app was testing a different package. Chain, from CI run 32532320970: FORGE_PACKAGES: pillow: -> pkg_version="" Staged recipe 'pillow' (dep: pillow) <- no pin app site-packages: pillow-12.3.0.dist-info recipe built: pillow 12.2.0 `packages="pillow:"` is the documented dispatch format and yields an EMPTY version, so stage_recipe.sh pinned nothing. The app resolves against PyPI as well as pypi.flet.dev, and PyPI now ships official iOS wheels for pillow — cp313 and cp314 only, no Android. 12.3.0 beat the freshly built 12.2.0 on version, and its _imaging carries jpeg2k_encoder and libtiff_encoder, which is exactly the {jpg, jpg_2000, libtiff, zlib} the test reported, webp absent and all. That also explains the shape of the failure that made it look like a platform bug: iOS 3.13 and 3.14 failed (PyPI has iOS wheels for those two Pythons), iOS 3.12 passed (no cp312 iOS wheel), and Android never failed at all (no Android wheels on PyPI). The dist-test/ build-tag bump to 9999 cannot prevent this: a build tag only breaks ties at the same version, and this is a version difference. So when no version is passed, take it from the wheel staged for the test rather than from meta.yaml — meta.yaml can be Jinja-conditional (cryptography builds a different version per Python) while the wheel on disk is what the device runs. When no wheel is found the script now says so loudly instead of quietly resolving whatever is newest. This was silent false coverage for any recipe whose upstream has started publishing mobile wheels, and that set is growing. --- tests/recipe-tester/stage_recipe.sh | 41 ++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/recipe-tester/stage_recipe.sh b/tests/recipe-tester/stage_recipe.sh index c294e9d1..e0a92e34 100755 --- a/tests/recipe-tester/stage_recipe.sh +++ b/tests/recipe-tester/stage_recipe.sh @@ -10,7 +10,7 @@ # # Examples: # ./stage_recipe.sh numpy 2.2.2 -# ./stage_recipe.sh pillow # no version pin +# ./stage_recipe.sh pillow # version taken from the built wheel # # Effects (idempotent): # - (re)creates ./recipe_tests/ with the recipe's pytest files @@ -48,6 +48,45 @@ fi # 2. Generate pyproject.toml from the template (gitignored): pin the recipe # under test (__RECIPE_DEP__) and expand test-only deps (__TEST_DEPS__) # from the recipe's meta.yaml `test.requires`. +# Pin the recipe under test. An UNPINNED dep is not a harmless default: the app +# resolves against PyPI as well as pypi.flet.dev, so a bare name takes whatever +# version is newest anywhere — and upstreams now publish their own mobile wheels. +# That is not hypothetical: pillow's iOS legs on 3.13/3.14 silently tested PyPI's +# pillow 12.3.0 (which ships official iOS wheels and carries jpg_2000 + libtiff) +# instead of the 12.2.0 this repo had just built, and reported a codec set no +# forge wheel has ever had. The dist-test/ build-tag bump to 9999 does not help: +# a build tag only breaks ties at the SAME version. +# +# So when no version is given, take it from the wheel actually staged for this +# test rather than from meta.yaml — meta.yaml can be Jinja-conditional (e.g. +# cryptography builds a different version per Python), while the wheel on disk is +# what the device will run. +if [ -z "$VERSION" ]; then + # Wheel names normalise the project name (Pillow -> pillow, ruamel.yaml.clib + # -> ruamel_yaml_clib), so compare normalised forms. `|| true` throughout: + # under `set -e` a non-matching grep would abort the script. + _rx="$(printf '%s' "$RECIPE" | tr 'A-Z.-' 'a-z__')" + for _d in "$REPO_ROOT/dist-test" "$REPO_ROOT/dist"; do + [ -d "$_d" ] || continue + _w="" + for _f in "$_d"/*.whl; do + [ -e "$_f" ] || continue + _b="$(basename "$_f")" + _n="$(printf '%s' "${_b%%-*}" | tr 'A-Z.-' 'a-z__')" + if [ "$_n" = "$_rx" ]; then _w="$_b"; break; fi + done + if [ -n "$_w" ]; then + # --[-]-... -> field 2 is the version + VERSION="$(printf '%s' "$_w" | cut -d- -f2)" + echo " no version given; pinned to $VERSION from $(basename "$_d")/$_w" + break + fi + done + if [ -z "$VERSION" ]; then + echo "::warning::no built wheel found for '$RECIPE' in dist-test/ or dist/; the app will resolve it UNPINNED and may install a different version than the one under test" >&2 + fi +fi + DEP="$RECIPE" [ -n "$VERSION" ] && DEP="$RECIPE==$VERSION" From da182eb3e267b4ff4f5acc092e7d8360267b7747 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 23 Aug 2026 01:37:13 +0200 Subject: [PATCH 06/11] recipes: move flet-libgdal's abstention note to the foot of the file Keeps the recipe's actual content at the top; the note explaining why there is no `about.license` reads fine at the end. No content change. --- recipes/flet-libgdal/meta.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/recipes/flet-libgdal/meta.yaml b/recipes/flet-libgdal/meta.yaml index b02b7309..32d3e9a8 100644 --- a/recipes/flet-libgdal/meta.yaml +++ b/recipes/flet-libgdal/meta.yaml @@ -1,8 +1,3 @@ -# No `about.license`, deliberately: the core is MIT but the wheel is not MIT-only. -# GDAL_BUILD_OPTIONAL_DRIVERS=OFF does not disable GTiff, and the cross build -# forces internal libtiff/libgeotiff/libjpeg/zlib/json-c/qhull/LercLib, which -# LICENSE.TXT does not enumerate. Compose it from a real build's CMake configure -# summary before setting one. LICENSE.TXT ships either way. {% set version = "3.13.1" %} package: @@ -23,3 +18,9 @@ requirements: patches: - mobile.patch + +# No `about.license`, deliberately: the core is MIT but the wheel is not MIT-only. +# GDAL_BUILD_OPTIONAL_DRIVERS=OFF does not disable GTiff, and the cross build +# forces internal libtiff/libgeotiff/libjpeg/zlib/json-c/qhull/LercLib, which +# LICENSE.TXT does not enumerate. Compose it from a real build's CMake configure +# summary before setting one. LICENSE.TXT ships either way. From bb2ce32ed47dc6666dcbb0e76ca2439c4c128aea Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 23 Aug 2026 01:37:32 +0200 Subject: [PATCH 07/11] forge: fail the build when a wheel would ship no licence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the missing-licence warning into a hard error, with an explicit in-recipe opt-out. A warning here was never going to hold the invariant. It lands in a build log thousands of lines long, on a job that exits 0 — which is exactly how every `flet-lib*` wheel came to ship with no notice at all while CI stayed green for years, and how the `about` key came to be documented but unimplemented. "We tried" is not the same guarantee as "it holds", and this is the one obligation every licence in the tree shares. The realistic way someone meets this is a **version bump** where upstream renames or relocates its notice — `LICENSE` moving into `LICENSES/`, or becoming `COPYING.md`. That is not a spurious block: it is the one moment where the alternative is silently shipping a violation, and the fix is one line. Three variants of exactly that turned up while writing this change (libpq/libxml2/libxslt keep theirs in `COPYRIGHT`, arrow in `NOTICE`, libiconv ships two where only one applies), so it is a live failure mode, not a hypothetical. So the error has to earn the interruption. It names the directories searched, the name patterns tried, and both fixes: flet-libfoo: no licence file found, so this wheel would ship the library's object code with no notice. searched (top level only): for names starting with: LICENSE / LICENCE / COPYING / COPYRIGHT / NOTICE (any case) Fix by pointing at the real file, ... about: license_file: path/to/LICENSE # or a list of paths A recipe with genuinely nothing to ship says so explicitly instead: about: license_file: [] # deliberately none -- An explicit `license_file` naming a file that isn't there now raises too, rather than warning and shipping nothing — that is a typo or a moved file, not a preference. The opt-out is an **empty list**, deliberately written in the recipe rather than passed as an environment variable, so it shows up in review next to the comment justifying it. Note the distinction the code has to be careful about: an unset `license_file` is the empty *string* the schema defaults to and still means "discover for me"; only an empty *list* opts out. Both are falsy, so the list check comes first. No recipe needs the opt-out today — all 27 pass — so this is free to turn on now. Verified against a stub builder across all branches: discovery (LICENSE, COPYRIGHT, source-shadows-recipe, recipe fallback), explicit sub-path, explicit list keeping two same-named notices apart, both error paths, the empty-list opt-out, and unset-still- discovers. Then a real `forge android:arm64-v8a flet-libiconv` build, which still bundles `COPYING.LIB` and now tags the wheel `-2-`. [skip ci] — a push here would rebuild only flet-libgdal (a comment move); the change that needs validating is the full 27-recipe matrix, which wants an explicit dispatch. --- .claude/skills/new-mobile-recipe/SKILL.md | 18 ++++++- src/forge/build.py | 64 ++++++++++++++++++----- src/forge/schema/meta-schema.yaml | 11 ++-- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/.claude/skills/new-mobile-recipe/SKILL.md b/.claude/skills/new-mobile-recipe/SKILL.md index 364271b3..c7387318 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -317,8 +317,22 @@ single wheel contains both. The *file* side splits the same way for free, becaus source-dir file shadows a recipe-dir file of the same name: build.sh stages the NDK notice as `LICENSE` on Android, and on iOS the recipe's own `LICENSE` is what remains. -**Watch the build log.** It prints `Bundling licence file: …`, or -`WARNING: no licence file found …` — the warning means that wheel would ship no notice. +**Finding no licence is a hard build error**, not a warning — a warning would sit unread in +a thousand-line log on a job that exits 0, which is exactly how every `flet-lib*` wheel came +to ship with no notice while CI stayed green. The error names the directories searched, the +name patterns tried, and the two fixes. You will most likely meet it on a **version bump** +where upstream renamed or relocated its notice; that is the moment you want stopping, and +the fix is one line. A recipe that genuinely has nothing to ship opts out explicitly, next +to a comment saying why: + +```yaml +about: + license_file: [] # deliberately none -- +``` + +Note an unset `license_file` still means "discover for me" — only an **empty list** is the +opt-out. On success the log prints `Bundling licence file: …`. + Scope the expression to what the wheel *contains*: libiconv ships `COPYING` (GPL, for the `iconv` program) beside `COPYING.LIB` (LGPL, for the library we actually build). diff --git a/src/forge/build.py b/src/forge/build.py index ebb7e463..6e6fe774 100644 --- a/src/forge/build.py +++ b/src/forge/build.py @@ -1154,6 +1154,16 @@ def collect_license_files(self) -> list[tuple[Path, str]]: that covers something the wheel does not contain, and including one that lives below the top level. + Finding nothing is an ERROR, not a warning. A warning here would be invisible: + it lands in a build log thousands of lines long, on a job that still exits 0 — + which is precisely how every flet-lib* wheel came to ship with no licence at all + while CI stayed green. An upstream that renames or relocates its notice during a + version bump is exactly when someone needs stopping, and the fix is one line. + The deliberate exception is written in the recipe, where a reviewer sees it: + + about: + license_file: [] # deliberately none -- + The relative path is preserved rather than flattened to a basename, both because PEP 639 records License-File that way and because flattening would silently drop one of two same-named notices (jq ships a COPYING for itself and @@ -1163,6 +1173,18 @@ def collect_license_files(self) -> list[tuple[Path, str]]: search_dirs = [self.build_path, self.package.recipe_path] explicit = about.get("license_file") + + # An explicit empty list is the opt-out; an unset field is the empty string the + # schema defaults to, meaning "discover for me". Both are falsy, so the list + # check has to come first. + if isinstance(explicit, list) and not explicit: + log( + self.log_file, + "about.license_file is empty: shipping no licence file, as the recipe " + "explicitly declares.", + ) + return [] + if explicit: wanted = [explicit] if isinstance(explicit, str) else list(explicit) resolved = [] @@ -1173,10 +1195,12 @@ def collect_license_files(self) -> list[tuple[Path, str]]: resolved.append((candidate, name)) break else: - log( - self.log_file, - f"WARNING: about.license_file '{name}' not found in the source " - "or recipe directory.", + raise RuntimeError( + f"{self.package.name}: about.license_file names {name!r}, which " + f"is not in the source directory ({self.build_path}) or the " + f"recipe directory ({self.package.recipe_path}). Point it at the " + f"licence file this version ships, or drop the setting to let it " + f"be discovered." ) return resolved @@ -1187,6 +1211,25 @@ def collect_license_files(self) -> list[tuple[Path, str]]: for candidate in sorted(directory.iterdir()): if candidate.is_file() and LICENSE_FILE_RE.match(candidate.name): found.setdefault(candidate.name, candidate) + + if not found: + raise RuntimeError( + f"{self.package.name}: no licence file found, so this wheel would ship " + f"the library's object code with no notice.\n" + f" searched (top level only):\n" + f" {self.build_path}\n" + f" {self.package.recipe_path}\n" + f" for names starting with: LICENSE / LICENCE / COPYING / COPYRIGHT / " + f"NOTICE (any case)\n" + f" Fix by pointing at the real file, which is what an upstream that " + f"moved or renamed its notice needs:\n" + f" about:\n" + f" license_file: path/to/LICENSE # or a list of paths\n" + f" A recipe with genuinely nothing to ship says so explicitly instead:\n" + f" about:\n" + f" license_file: [] # deliberately none -- " + ) + return [(path, name) for name, path in found.items()] def make_wheel(self): @@ -1213,12 +1256,8 @@ def make_wheel(self): + ("files: " if len(license_files) > 1 else "file: ") + ", ".join(relative for _, relative in license_files), ) - else: - log( - self.log_file, - "WARNING: no licence file found in the source or recipe directory. " - "Set about.license_file, or add the notice to the recipe directory.", - ) + # The only way to reach an empty list is the explicit `license_file: []` + # opt-out — anything else has already raised in collect_license_files(). # Write the packaging metadata self.write_message_file( @@ -1231,10 +1270,9 @@ def make_wheel(self): "Tag": self.wheel_tag, }, ) - # Metadata-Version 2.4 is the floor for PEP 639's License-Expression and - # License-File; below it, a consumer or licence scanner has no way to see - # what the wheel wraps short of unpacking it. metadata = { + # 2.4 is the floor for PEP 639's License-Expression and License-File; below it, + # a consumer/licence-scanner has no way to see what the wheel wraps short of unpacking it. "Metadata-Version": "2.4", "Name": self.package.name, "Version": self.package.version, diff --git a/src/forge/schema/meta-schema.yaml b/src/forge/schema/meta-schema.yaml index 18d6e3ca..0789e335 100644 --- a/src/forge/schema/meta-schema.yaml +++ b/src/forge/schema/meta-schema.yaml @@ -233,7 +233,6 @@ properties: - type: array items: type: string - minItems: 1 default: "" description: >- Path, or list of paths, relative to the source directory (or to @@ -246,9 +245,13 @@ properties: it cannot get right: excluding a notice covering something the wheel doesn't contain (libiconv's top-level COPYING is the GPL for the `iconv` program, not the LGPL library we build), and including - one below the top level (a bundled library's own notice). Applies - to build.sh recipes: a Python package's build backend already - carries its licence through. + one below the top level (a bundled library's own notice). + Finding no licence at all is a BUILD ERROR, since the wheel would + otherwise ship object code with no notice; an EMPTY LIST is the + deliberate opt-out for a recipe that genuinely has nothing to ship, + and belongs next to a comment saying why. Applies to build.sh + recipes: a Python package's build backend already carries its + licence through. license: type: string From 0d67bd0a1a0bd98225c6da95a76ea12e927fa72f Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 23 Aug 2026 01:56:26 +0200 Subject: [PATCH 08/11] Revert "recipe-tester: pin the recipe under test to the wheel that was built [skip ci]" This reverts commit 193f898189135f6f1590fa30ff9055e571f74f18. --- tests/recipe-tester/stage_recipe.sh | 41 +---------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/tests/recipe-tester/stage_recipe.sh b/tests/recipe-tester/stage_recipe.sh index e0a92e34..c294e9d1 100755 --- a/tests/recipe-tester/stage_recipe.sh +++ b/tests/recipe-tester/stage_recipe.sh @@ -10,7 +10,7 @@ # # Examples: # ./stage_recipe.sh numpy 2.2.2 -# ./stage_recipe.sh pillow # version taken from the built wheel +# ./stage_recipe.sh pillow # no version pin # # Effects (idempotent): # - (re)creates ./recipe_tests/ with the recipe's pytest files @@ -48,45 +48,6 @@ fi # 2. Generate pyproject.toml from the template (gitignored): pin the recipe # under test (__RECIPE_DEP__) and expand test-only deps (__TEST_DEPS__) # from the recipe's meta.yaml `test.requires`. -# Pin the recipe under test. An UNPINNED dep is not a harmless default: the app -# resolves against PyPI as well as pypi.flet.dev, so a bare name takes whatever -# version is newest anywhere — and upstreams now publish their own mobile wheels. -# That is not hypothetical: pillow's iOS legs on 3.13/3.14 silently tested PyPI's -# pillow 12.3.0 (which ships official iOS wheels and carries jpg_2000 + libtiff) -# instead of the 12.2.0 this repo had just built, and reported a codec set no -# forge wheel has ever had. The dist-test/ build-tag bump to 9999 does not help: -# a build tag only breaks ties at the SAME version. -# -# So when no version is given, take it from the wheel actually staged for this -# test rather than from meta.yaml — meta.yaml can be Jinja-conditional (e.g. -# cryptography builds a different version per Python), while the wheel on disk is -# what the device will run. -if [ -z "$VERSION" ]; then - # Wheel names normalise the project name (Pillow -> pillow, ruamel.yaml.clib - # -> ruamel_yaml_clib), so compare normalised forms. `|| true` throughout: - # under `set -e` a non-matching grep would abort the script. - _rx="$(printf '%s' "$RECIPE" | tr 'A-Z.-' 'a-z__')" - for _d in "$REPO_ROOT/dist-test" "$REPO_ROOT/dist"; do - [ -d "$_d" ] || continue - _w="" - for _f in "$_d"/*.whl; do - [ -e "$_f" ] || continue - _b="$(basename "$_f")" - _n="$(printf '%s' "${_b%%-*}" | tr 'A-Z.-' 'a-z__')" - if [ "$_n" = "$_rx" ]; then _w="$_b"; break; fi - done - if [ -n "$_w" ]; then - # --[-]-... -> field 2 is the version - VERSION="$(printf '%s' "$_w" | cut -d- -f2)" - echo " no version given; pinned to $VERSION from $(basename "$_d")/$_w" - break - fi - done - if [ -z "$VERSION" ]; then - echo "::warning::no built wheel found for '$RECIPE' in dist-test/ or dist/; the app will resolve it UNPINNED and may install a different version than the one under test" >&2 - fi -fi - DEP="$RECIPE" [ -n "$VERSION" ] && DEP="$RECIPE==$VERSION" From 4357a105149af5222f617c419b5dc9f4d7f1f183 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 23 Aug 2026 19:54:40 +0200 Subject: [PATCH 09/11] skills: the licensing rules reach the two skills that needed them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only `new-mobile-recipe` covered any of this; the two skills someone actually reaches for when it bites had nothing. **forge-error-catalogue** — a new entry for the hard failure this branch introduces. That error is designed to be actionable, and the catalogue is where people grep when a build stops, so it not being there defeated the point. Records the cause, that the likely trigger is a version bump rather than a new recipe, the `license_file` fix in both its forms, the exclude case (libiconv's GPL `COPYING` vs its LGPL `COPYING.LIB`), and the unset-vs-empty-list distinction that decides whether discovery runs at all. **native-recipe-bumps** — a bump is the likeliest moment for a licence to go wrong, in two ways that behave very differently. The FILE moving is loud: the build now fails and the error names the fix. The EXPRESSION changing is silent, because `about.license` is a string nobody re-reads — `libpng` is `Libpng` up to 1.6.35 and `libpng-2.0` after; `libtiff` 4.7.0's LICENSE.md omits the `tif_lzw.c` Berkeley grant that 4.7.1 adds. Both are real, both were found the hard way, and only the second can ship wrong. Also cross-links the publish trap from where licences are actually edited. `forge-ci` already documented "bump before republishing" — the knowledge was captured and I simply didn't apply it, which cost a commit to notice. A pointer from the licensing section is the cheap fix, since editing metadata needs no version change and so gives no other cue. No new facts invented here: everything is from this branch's work. --- .../references/failure-catalogue.md | 42 +++++++++++++++++++ .claude/skills/native-recipe-bumps/SKILL.md | 24 +++++++++++ .claude/skills/new-mobile-recipe/SKILL.md | 6 +++ 3 files changed, 72 insertions(+) diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index ef80ccf3..5ea5f2bf 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -2000,6 +2000,48 @@ face3d, still counts as pure.) --- +### `: no licence file found, so this wheel would ship the library's object code with no notice` + +**Cause:** a `build.sh` recipe's wheel is synthesised by forge, so nothing carries a +licence into it unless forge finds one. It looks for a **top-level** file in the source +or recipe directory whose name starts with `LICEN[CS]E` / `COPYING` / `COPYRIGHT` / +`NOTICE` (any case) and, finding none, **fails the build**. Every licence in this tree +requires its notice to accompany the binary, and a warning here proved worthless — it sat +unread in a thousand-line log on a job that exits 0, which is how every `flet-lib*` wheel +shipped with no notice at all for years while CI stayed green. + +**You will almost always hit this on a VERSION BUMP**, not a new recipe: upstream renamed +or relocated its notice (`LICENSE` → `LICENSES/`, or into a subdirectory). That is exactly +the moment worth stopping, because the alternative is silently shipping a violation. + +**Fix:** point at the real file — a path, or a list of them, resolved against the source +then the recipe directory: + +```yaml +about: + license_file: docs/FTL.TXT # or: [COPYING, modules/oniguruma/COPYING] +``` + +Setting it **replaces** auto-discovery, which is also how you *exclude* a notice covering +something the wheel doesn't contain (libiconv's top-level `COPYING` is the GPL for the +`iconv` program build.sh deletes; only `COPYING.LIB` applies to the library). A recipe with +genuinely nothing to ship opts out explicitly, next to a comment saying why: + +```yaml +about: + license_file: [] # deliberately none -- +``` + +Note an **unset** `license_file` still means "discover for me" — only an empty **list** is +the opt-out. + +**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 +republishing". Full authoring guidance in `new-mobile-recipe` § 3.5b. + +--- + ## Diagnostic snippets ### Inspect a wheel's contents diff --git a/.claude/skills/native-recipe-bumps/SKILL.md b/.claude/skills/native-recipe-bumps/SKILL.md index 40d8de8a..f33a39f0 100644 --- a/.claude/skills/native-recipe-bumps/SKILL.md +++ b/.claude/skills/native-recipe-bumps/SKILL.md @@ -188,6 +188,30 @@ patch --dry-run -p1 --ignore-whitespace < /path/to/recipes//patches/mobile- Render with both `sdk='iphoneos'` and `sdk='android'` whenever the file has SDK conditionals. +## Licences: the two things a bump can invalidate + +A bump is the likeliest moment for both, and neither announces itself: + +- **The licence FILE moves or is renamed.** forge auto-bundles a top-level + `LICEN[CS]E*`/`COPYING*`/`COPYRIGHT*`/`NOTICE*` from the source or recipe directory, and + **fails the build** when it finds none — so this one stops you, loudly, with the fix in + the error. Point `about.license_file` at the new location (a path or a list). Check it + cheaply before building: + ```bash + tar tf downloads/-.tar.gz | awk -F/ 'NF==2' | grep -iE 'licen|copying|copyright|notice' + ``` +- **The licence EXPRESSION changes at a version boundary** — this one is silent, because + `about.license` is just a string forge copies into the metadata. Two real cases: + `libpng` is `Libpng` up to 1.6.35 and `libpng-2.0` after; `libtiff` 4.7.0's `LICENSE.md` + omits the second Berkeley grant covering `tif_lzw.c`, which upstream added in 4.7.1, so + the correct expression differs between those two releases. Re-read the licence text in + the archive you are now shipping from, not the one you shipped last time. + +And whichever changed: **licence metadata does not reach pypi.flet.dev until the build +number is bumped** — an existing `-` 409-skips on re-publish. A version +bump usually moves the version anyway, so this mostly bites when you fix metadata *without* +a version change. See `forge-ci` § Deploying. + ## Build / debug loop `forge` takes a *host* (top-level platform name like `iOS`/`android`, or a `platform:arch` / `platform:version:arch` triple) followed by one or more recipe names. There is no `build` subcommand. diff --git a/.claude/skills/new-mobile-recipe/SKILL.md b/.claude/skills/new-mobile-recipe/SKILL.md index c7387318..d4f9c1c9 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -336,6 +336,12 @@ opt-out. On success the log prints `Bundling licence file: …`. Scope the expression to what the wheel *contains*: libiconv ships `COPYING` (GPL, for the `iconv` program) beside `COPYING.LIB` (LGPL, for the library we actually build). +**Changing licence metadata on an already-published recipe does nothing until the build +number is bumped** — the re-publish 409-skips otherwise, so the fix never reaches +pypi.flet.dev. This is easy to miss precisely because the metadata edit needs no version +change. See `forge-ci` § Deploying, "Bump before republishing"; the error the *build* side +produces is catalogued in `forge-error-catalogue` § "no licence file found". + ### 3.6 — Cross-cutting conventions from the ML wave (CMake-heavy recipes) - **Version as a Jinja constant.** A bare `{% set version = "X.Y.Z" %}` as the first line, reused in `package.version` AND `source.url` — bumps become one-line edits. See `recipes/faiss-cpu/meta.yaml`; onnxruntime uses the same idiom. From b59833a871749a3d9479dc0cf74408cc1fa2b3b7 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Mon, 24 Aug 2026 02:17:18 +0200 Subject: [PATCH 10/11] recipes: flet-libcpp-shared keeps the NDK notice under its own name [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staged as `LICENSE` originally because the matcher was `licen[cs]e|copying|copyright` at the time and would not have found a `NOTICE`. `NOTICE` was added to it afterwards, for Apache-2.0 section 4(d) and arrow, so the rename now buys nothing here — and it costs the filename a compliance reviewer actually looks for. flet-libomp still stages the same NDK file as `LICENSE`, and must: dedup is by filename, source directory first, so that name is what shadows the recipe-level `LICENSE` covering its iOS stub. Staging it as `NOTICE` there would collect both and put this repo's BSD-3-Clause into an Android wheel that contains none of its code. The asymmetry between two otherwise-parallel recipes is deliberate, and now says so in both files. Verified: `forge android:arm64-v8a flet-libcpp-shared` bundles `dist-info/licenses/NOTICE` (130,424 B) with `License-File: NOTICE` and the unchanged `License-Expression: Apache-2.0 WITH LLVM-exception`. --- recipes/flet-libcpp-shared/build.sh | 2 +- recipes/flet-libomp/build.sh | 2 +- src/forge/schema/meta-schema.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/recipes/flet-libcpp-shared/build.sh b/recipes/flet-libcpp-shared/build.sh index 62e62404..89cbd01f 100755 --- a/recipes/flet-libcpp-shared/build.sh +++ b/recipes/flet-libcpp-shared/build.sh @@ -15,4 +15,4 @@ cp $LIBC_SHARED_SO $PREFIX/lib # This recipe has no upstream archive, so nothing carries a licence into the wheel on # its own. libc++_shared.so is LLVM's, under Apache-2.0 WITH LLVM-exception; take the # notice from the same NDK the .so was copied from so the two can never drift. -cp "$toolchain/NOTICE" ./LICENSE +cp "$toolchain/NOTICE" ./NOTICE diff --git a/recipes/flet-libomp/build.sh b/recipes/flet-libomp/build.sh index 2c16e136..71e26eca 100755 --- a/recipes/flet-libomp/build.sh +++ b/recipes/flet-libomp/build.sh @@ -30,7 +30,7 @@ android) # No upstream archive, so nothing carries a licence into the wheel on its own. # This is LLVM's runtime, under Apache-2.0 WITH LLVM-exception; take the notice # from the same NDK the .so was copied from so the two can never drift. Named - # LICENSE so it shadows the recipe's own copy, which covers only the iOS stub + # LICENSE so it shadows the recipe's own, which covers only the iOS stub # below — the two platforms ship different code under different licences. cp "$toolchain/NOTICE" ./LICENSE ;; diff --git a/src/forge/schema/meta-schema.yaml b/src/forge/schema/meta-schema.yaml index 0789e335..bca6bc52 100644 --- a/src/forge/schema/meta-schema.yaml +++ b/src/forge/schema/meta-schema.yaml @@ -249,7 +249,7 @@ properties: Finding no licence at all is a BUILD ERROR, since the wheel would otherwise ship object code with no notice; an EMPTY LIST is the deliberate opt-out for a recipe that genuinely has nothing to ship, - and belongs next to a comment saying why. Applies to build.sh + and belongs next to a comment saying why. Applies to `build.sh` recipes: a Python package's build backend already carries its licence through. From da4fceddcd453b46e1f3453d6a6c780fd280d49b Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Mon, 24 Aug 2026 02:47:38 +0200 Subject: [PATCH 11/11] Run CI