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 eea30e31..d4f9c1c9 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -260,6 +260,88 @@ 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. + +**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. + +**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). + +**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. diff --git a/recipes/flet-libarrow/meta.yaml b/recipes/flet-libarrow/meta.yaml index 443a6a62..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 @@ -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..89cbd01f 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" ./NOTICE diff --git a/recipes/flet-libcpp-shared/meta.yaml b/recipes/flet-libcpp-shared/meta.yaml index 4bb39d42..f9d7fdc3 100644 --- a/recipes/flet-libcpp-shared/meta.yaml +++ b/recipes/flet-libcpp-shared/meta.yaml @@ -7,7 +7,10 @@ package: platforms: [android] build: - number: 10 + number: 11 # 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..b7e63241 100644 --- a/recipes/flet-libcrc32c/meta.yaml +++ b/recipes/flet-libcrc32c/meta.yaml @@ -5,11 +5,14 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://github.com/google/crc32c/archive/refs/tags/{{ version }}.tar.gz 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..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). @@ -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..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 @@ -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..32d3e9a8 100644 --- a/recipes/flet-libgdal/meta.yaml +++ b/recipes/flet-libgdal/meta.yaml @@ -8,7 +8,7 @@ source: url: https://github.com/OSGeo/gdal/releases/download/v{{ version }}/gdal-{{ version }}.tar.gz build: - number: 1 + number: 2 requirements: build: @@ -18,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. diff --git a/recipes/flet-libgeos/meta.yaml b/recipes/flet-libgeos/meta.yaml index 3b59c5f1..74ba0705 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: @@ -5,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 26b4b588..2db6b74c 100644 --- a/recipes/flet-libiconv/meta.yaml +++ b/recipes/flet-libiconv/meta.yaml @@ -10,7 +10,14 @@ package: platforms: [android] build: - number: 1 + number: 2 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..6918e805 100644 --- a/recipes/flet-libjpeg/meta.yaml +++ b/recipes/flet-libjpeg/meta.yaml @@ -8,8 +8,12 @@ source: url: https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/{{ version }}/libjpeg-turbo-{{ version }}.tar.gz build: - number: 10 + number: 11 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..5a813eea 100644 --- a/recipes/flet-libjq/meta.yaml +++ b/recipes/flet-libjq/meta.yaml @@ -8,11 +8,18 @@ source: url: https://github.com/jqlang/jq/releases/download/jq-{{ version }}/jq-{{ version }}.tar.gz build: - number: 11 + number: 12 requirements: build: - 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..86cfeaba 100644 --- a/recipes/flet-libmagic/meta.yaml +++ b/recipes/flet-libmagic/meta.yaml @@ -5,9 +5,12 @@ package: version: '{{ version }}' build: - number: 1 + number: 2 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..71e26eca 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, 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..302d63ff 100644 --- a/recipes/flet-libomp/meta.yaml +++ b/recipes/flet-libomp/meta.yaml @@ -19,8 +19,19 @@ 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. 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 %} diff --git a/recipes/flet-libopaque/meta.yaml b/recipes/flet-libopaque/meta.yaml index 19c1492b..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 @@ -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..10978ac2 100644 --- a/recipes/flet-libopenblas/meta.yaml +++ b/recipes/flet-libopenblas/meta.yaml @@ -5,7 +5,14 @@ package: version: '{{ version }}' build: - number: 0 + number: 1 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..a43e4cdc 100644 --- a/recipes/flet-libpng/meta.yaml +++ b/recipes/flet-libpng/meta.yaml @@ -5,10 +5,14 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 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..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). @@ -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..a78b29f1 100644 --- a/recipes/flet-libpsl/meta.yaml +++ b/recipes/flet-libpsl/meta.yaml @@ -8,7 +8,15 @@ source: url: https://github.com/rockdaboot/libpsl/releases/download/{{ version }}/libpsl-{{ version }}.tar.gz build: - number: 10 + number: 11 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..1de48acb 100644 --- a/recipes/flet-libpyjni/meta.yaml +++ b/recipes/flet-libpyjni/meta.yaml @@ -8,11 +8,14 @@ package: platforms: [android] build: - number: 10 + number: 11 source: url: https://github.com/flet-dev/libpyjni/releases/download/v{{ version }}/pyjni-{{ version }}.tar.gz 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..50bc4ab2 100644 --- a/recipes/flet-libsodium/meta.yaml +++ b/recipes/flet-libsodium/meta.yaml @@ -5,7 +5,10 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 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..6583381d 100755 --- a/recipes/flet-libxml2/meta.yaml +++ b/recipes/flet-libxml2/meta.yaml @@ -10,10 +10,14 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 source: url: https://download.gnome.org/sources/libxml2/{{ version.rsplit('.', 1)[0] }}/libxml2-{{ version }}.tar.xz 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..20e756df 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" %} @@ -12,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 3a1909ab..72d2857c 100644 --- a/recipes/flet-libyaml/meta.yaml +++ b/recipes/flet-libyaml/meta.yaml @@ -5,7 +5,10 @@ package: version: '{{ version }}' build: - number: 10 + number: 11 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..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: @@ -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 diff --git a/src/forge/build.py b/src/forge/build.py index 38a20a65..6e6fe774 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,104 @@ 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. + + 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 + 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") + + # 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 = [] + for name in wanted: + for directory in search_dirs: + candidate = directory / name + if candidate.is_file(): + resolved.append((candidate, name)) + break + else: + 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 + + 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) + + 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): build_num = str(self.package.meta["build"]["number"]) name = canonicalize_name(self.package.name) @@ -1112,6 +1243,22 @@ 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), + ) + # 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( info_path / "WHEEL", @@ -1123,16 +1270,21 @@ 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 = { + # 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, + "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..bca6bc52 100644 --- a/src/forge/schema/meta-schema.yaml +++ b/src/forge/schema/meta-schema.yaml @@ -228,14 +228,41 @@ properties: properties: license_file: + oneOf: + - type: string + - type: array + items: + type: string + 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). + 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 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