From cf88b1f299a1acee07e1f8196e37f5abf0758dc1 Mon Sep 17 00:00:00 2001 From: zackees Date: Sun, 19 Apr 2026 01:48:44 -0700 Subject: [PATCH 1/4] fix(publish): set S_IFREG on wheel script entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.1.20 shipped with mode=0o755 on the `fbuild` / `fbuild-daemon` console scripts but with external_attr=0x01ed0000 — i.e. the mode bits were set correctly but the file-type bit (S_IFREG) was zero. pip's wheel installer calls stat.S_ISREG() on the upper 16 bits of external_attr before deciding whether to apply the script's mode; without the IFREG bit that test returns False, pip falls back to umask defaults (0o644), and the binary lands on disk without +x: /opt/hostedtoolcache/Python/3.12.13/x64/bin/fbuild: Permission denied (exit code 126) Windows doesn't care about exec bits on .exe files, which is why 2.1.20 looked fine on `uv tool install fbuild==2.1.20` on Windows but was broken for every Linux/macOS user. It's also the root cause of FastLED/fbuild#129 — #135's "preserve exec bit" fix set the mode but not the file-type bit. Reference: uv / ruff / maturin-built wheels all have external_attr=0x81ed0000 (S_IFREG | 0o755) on their script entries. Verified locally by rebuilding the Linux x86_64 wheel against the existing binary artifact: new external_attr=0x81ed0000, IFREG=True. Co-Authored-By: Claude Opus 4.7 (1M context) --- ci/publish.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ci/publish.py b/ci/publish.py index 56d76ecc..97b83d4c 100644 --- a/ci/publish.py +++ b/ci/publish.py @@ -426,8 +426,16 @@ def build_wheel( for pt in plat_tags: wheel_meta += f"Tag: {tag_prefix}-{pt}\n" + # S_IFREG is required — pip's wheel installer calls S_ISREG() on the + # upper 16 bits of external_attr and falls back to the umask default + # (0o644) if the file-type bit is missing, regardless of the mode + # bits set. That's how 2.1.20 shipped with mode=0o755 but still + # `/bin/fbuild: Permission denied` on every Linux/macOS install. + # Reference: uv/ruff wheels have external_attr 0x81ed0000 + # (S_IFREG | 0o755); 2.1.20 had 0x01ed0000. exec_perms = ( - stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR + stat.S_IFREG + | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH ) From 7b48c5a94df85cec67d17479129eae9c08159148 Mon Sep 17 00:00:00 2001 From: zackees Date: Sun, 19 Apr 2026 01:48:56 -0700 Subject: [PATCH 2/4] chore: bump version to 2.1.21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.1.20 is stuck on PyPI with unusable Linux/macOS wheels (missing S_IFREG on `fbuild` / `fbuild-daemon` script entries → `Permission denied` on every non-Windows install). The fix from the previous commit only takes effect on a fresh release; PyPI does not allow overwriting an existing version's wheel filenames. Release notes vs 2.1.20: - fix(publish): set S_IFREG on wheel script entries so pip applies +x to bundled binaries on Linux/macOS - fix(publish): per-wheel concurrent upload with post-upload verification, so partial uploads never silently succeed Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 22 +++++++++++----------- Cargo.toml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 608d18cf..b4caaeb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -755,7 +755,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fbuild-build" -version = "2.1.20" +version = "2.1.21" dependencies = [ "async-trait", "fbuild-config", @@ -775,7 +775,7 @@ dependencies = [ [[package]] name = "fbuild-cli" -version = "2.1.20" +version = "2.1.21" dependencies = [ "blake3", "clap", @@ -798,7 +798,7 @@ dependencies = [ [[package]] name = "fbuild-config" -version = "2.1.20" +version = "2.1.21" dependencies = [ "fbuild-core", "include_dir", @@ -812,7 +812,7 @@ dependencies = [ [[package]] name = "fbuild-core" -version = "2.1.20" +version = "2.1.21" dependencies = [ "libc", "running-process-core", @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "fbuild-daemon" -version = "2.1.20" +version = "2.1.21" dependencies = [ "async-trait", "axum", @@ -864,7 +864,7 @@ dependencies = [ [[package]] name = "fbuild-deploy" -version = "2.1.20" +version = "2.1.21" dependencies = [ "async-trait", "espflash", @@ -886,7 +886,7 @@ dependencies = [ [[package]] name = "fbuild-packages" -version = "2.1.20" +version = "2.1.21" dependencies = [ "axum", "bzip2", @@ -913,14 +913,14 @@ dependencies = [ [[package]] name = "fbuild-paths" -version = "2.1.20" +version = "2.1.21" dependencies = [ "fbuild-core", ] [[package]] name = "fbuild-python" -version = "2.1.20" +version = "2.1.21" dependencies = [ "base64", "fbuild-core", @@ -940,7 +940,7 @@ dependencies = [ [[package]] name = "fbuild-serial" -version = "2.1.20" +version = "2.1.21" dependencies = [ "async-trait", "base64", @@ -962,7 +962,7 @@ dependencies = [ [[package]] name = "fbuild-test-support" -version = "2.1.20" +version = "2.1.21" dependencies = [ "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 67ab025b..016fd846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "2.1.20" +version = "2.1.21" edition = "2021" rust-version = "1.75" license = "MIT OR Apache-2.0" diff --git a/pyproject.toml b/pyproject.toml index 7b4e5b10..d12d308c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fbuild" -version = "2.1.20" +version = "2.1.21" description = "PlatformIO-compatible embedded build tool (Rust implementation)" requires-python = ">=3.10" dependencies = ["zccache>=1.2.12"] diff --git a/uv.lock b/uv.lock index 60019d17..34c118f6 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "fbuild" -version = "2.1.20" +version = "2.1.21" source = { editable = "." } dependencies = [ { name = "zccache" }, From 4683d5405ed556019eafb46e22c5132267368815 Mon Sep 17 00:00:00 2001 From: zackees Date: Sun, 19 Apr 2026 02:18:49 -0700 Subject: [PATCH 3/4] chore(deps): require zccache>=1.2.13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.2.12 is the last zccache release that ships broken Linux/macOS wheels — the bundled `zccache` binary lacks +x and every cache operation fails with `Permission denied`. The upstream fix landed in zccache 1.2.13 (zackees/zccache#35: S_IFREG + create_system=3 on wheel script entries). Bumping the floor so fresh pip installs of fbuild 2.1.21 don't pin themselves to a broken zccache. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- uv.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d12d308c..a629e8f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "fbuild" version = "2.1.21" description = "PlatformIO-compatible embedded build tool (Rust implementation)" requires-python = ">=3.10" -dependencies = ["zccache>=1.2.12"] +dependencies = ["zccache>=1.2.13"] [dependency-groups] dev = ["fbuild-dev-tools"] diff --git a/uv.lock b/uv.lock index 34c118f6..53ac3336 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "zccache", specifier = ">=1.2.12" }] +requires-dist = [{ name = "zccache", specifier = ">=1.2.13" }] [package.metadata.requires-dev] dev = [{ name = "fbuild-dev-tools", editable = "ci/dev-tools" }] @@ -47,13 +47,13 @@ wheels = [ [[package]] name = "zccache" -version = "1.2.12" +version = "1.2.13" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/c7/a0e100342f30a928d027156046b3d510806b00abd70d3a780080fa18b753/zccache-1.2.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b26ca9fbd8c4ccd36023b657f9af699c5cbf5a805af7164cec5ed8b276e5e989", size = 11488714, upload-time = "2026-04-18T20:22:06.087Z" }, - { url = "https://files.pythonhosted.org/packages/b9/1a/860600f40f771244bd8bbecced9d6e356cb34cf6e0ce46aa580ab7bf1f9d/zccache-1.2.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89d475e4ee11a0195b64da57c53f4b06376003da5d347891a12bfb363ad94269", size = 10978827, upload-time = "2026-04-18T20:22:34.72Z" }, - { url = "https://files.pythonhosted.org/packages/30/e2/d906f9184cfd403f6402826484cb4277af4a125a296f75c8abb3d1ffacef/zccache-1.2.12-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e7096a9716764e094c09510445a25e3ce105341ed6c498aa203622fa53eec939", size = 10941646, upload-time = "2026-04-18T20:22:57.647Z" }, - { url = "https://files.pythonhosted.org/packages/04/7b/e3548c4b55296f6f08f35f3bddb1cf67ccb048cb354e7f411e6f23810333/zccache-1.2.12-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:0ebacaeb0ad3080ac81a02b00267b6d6f178ca8e6ab83f0d73a424a0e6323045", size = 11886978, upload-time = "2026-04-18T20:23:24.411Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a9/c11a2f64f847d457536fc94c825dabae4a476b011c49086b53b398d70ae6/zccache-1.2.12-py3-none-win_amd64.whl", hash = "sha256:ada4bfd47c56f65aff5ce5db9e210f093bf90381705d1043ab1ad0ea5ff9d34d", size = 10714093, upload-time = "2026-04-18T20:23:46.504Z" }, - { url = "https://files.pythonhosted.org/packages/99/23/702306c7e5d8a55fc443acab81475f9469186527be577d51d9b2277b6197/zccache-1.2.12-py3-none-win_arm64.whl", hash = "sha256:d368addaa5b5bbd102406838e86b6621ec2a9451e653e1b64078a83b454e42f3", size = 9955945, upload-time = "2026-04-18T20:24:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/87/73/4080b898921a865faff716717e1a198846c0ff256f38cbea54b28ea81fbb/zccache-1.2.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25c01b6259118c226d7d9adb8fe854eca74e47d622023685553eb5d16afe69a9", size = 11488674, upload-time = "2026-04-19T09:12:46.718Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/4d5f45dabb676bcfdc1f38945675ea8ce06ca47b057cdd755112cf393f5a/zccache-1.2.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ca4b87d9110e23848a074a02887a3427868936915c387e9b9ffc7b39aaa38b30", size = 10981829, upload-time = "2026-04-19T09:13:03.894Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1e/f4a222a3addc2227abc067459ad0128a5aaf0179c8d460c8872476bfe1f0/zccache-1.2.13-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:1cfc01e890ced64349a10ca7fa1973750fdadc6aad027c2a9eab1d5803b08f45", size = 10942113, upload-time = "2026-04-19T09:13:21.216Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/485a8c67a7a86fa4e7b1032266d761427d4d97de6a70da2e9cb6d4025e3e/zccache-1.2.13-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:9bdf895a0c24933adb28c57ef99948083d05adbf7220a5f643e4be7c504171c8", size = 11887723, upload-time = "2026-04-19T09:13:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7f8c8ced3eba832990bef18489bc5b419a4b3e5f1fe14727a5c123d6b9cb/zccache-1.2.13-py3-none-win_amd64.whl", hash = "sha256:7ed67d6bdf92b1e6f142aaf987c72f751824c8fcf9c18fd0401479f46d7ebca1", size = 10713286, upload-time = "2026-04-19T09:14:01.432Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/95ebc437fbf581cee66484ccf455f2c0bf5729e264b800646ed1a06ec87c/zccache-1.2.13-py3-none-win_arm64.whl", hash = "sha256:117cd479adaa1c1aeca197fee7d86d9e59845807a82c03e3141bfd23815257cb", size = 9956694, upload-time = "2026-04-19T09:14:17.552Z" }, ] From 6652ae5fd4c79171ea24ff4ba3fab77c1ed48504 Mon Sep 17 00:00:00 2001 From: zackees Date: Sun, 19 Apr 2026 02:23:14 -0700 Subject: [PATCH 4/4] feat(publish): embed README in wheel METADATA so PyPI renders it The fbuild PyPI project page has been blank because the hand-built wheels' METADATA only carried the four required headers (Name, Version, Summary, Requires-Python) with no description body. PyPI falls back to the project's one-line summary when no description is present, so users landing on pypi.org/project/fbuild/ saw one line of text instead of the README. - Add `readme = "README.md"` to `[project]` in pyproject.toml so standard packaging tools (and the inspection snippet below) can discover the README file without guessing. - Teach `read_project_meta()` to load the file when present. - Extend wheel METADATA with `Description-Content-Type: text/markdown` and the README body, per PEP 566. fbuild's Rust crates are not published to crates.io (no `cargo publish` in `ci/publish.py`, no PUBLISHABLE_CRATES list, and none of `fbuild-core`, `fbuild-cli`, etc. exist on crates.io), so no Cargo.toml `readme = "README.md"` lines are needed here. Rides with 2.1.21. Verified: rebuilt the Linux x86_64 wheel locally, METADATA now contains the Description-Content-Type header and a 24 KB markdown body. Co-Authored-By: Claude Opus 4.7 (1M context) --- ci/publish.py | 24 ++++++++++++++++++------ pyproject.toml | 1 + 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/ci/publish.py b/ci/publish.py index 97b83d4c..6332fb99 100644 --- a/ci/publish.py +++ b/ci/publish.py @@ -84,16 +84,23 @@ def run_capture(cmd: list[str]) -> str: return result.stdout.strip() -def read_project_meta() -> tuple[str, str, str, str]: - """Return (name, version, summary, requires_python) from pyproject.toml.""" +def read_project_meta() -> tuple[str, str, str, str, str]: + """Return (name, version, summary, requires_python, readme) from pyproject.toml.""" with open(ROOT / "pyproject.toml", "rb") as f: data = tomllib.load(f) proj = data["project"] + readme = "" + readme_field = proj.get("readme") + if readme_field: + readme_path = ROOT / (readme_field if isinstance(readme_field, str) else readme_field.get("file", "")) + if readme_path.exists(): + readme = readme_path.read_text(encoding="utf-8") return ( proj["name"], proj["version"], proj.get("description", ""), proj.get("requires-python", ">=3.10"), + readme, ) @@ -377,6 +384,7 @@ def build_wheel( version: str, summary: str, requires_python: str, + readme: str, platform_subdir: str, plat_tags: list[str], ) -> Path | None: @@ -417,6 +425,10 @@ def build_wheel( f"Summary: {summary}\n" f"Requires-Python: {requires_python}\n" ) + if readme: + # Per PEP 566: blank line separates headers from the description body, + # and Description-Content-Type declares the rendering format PyPI uses. + metadata += f"Description-Content-Type: text/markdown\n\n{readme}\n" wheel_meta = ( f"Wheel-Version: 1.0\n" @@ -501,7 +513,7 @@ def add_file(whl: zipfile.ZipFile, arcname: str, data: bytes, executable: bool = return wheel_path -def build_all_wheels(name: str, version: str, summary: str, requires_python: str) -> list[Path]: +def build_all_wheels(name: str, version: str, summary: str, requires_python: str, readme: str) -> list[Path]: log(f"\n=== Step 4: Build wheels ({name} {version}) ===") if WHEEL_DIR.exists(): @@ -510,7 +522,7 @@ def build_all_wheels(name: str, version: str, summary: str, requires_python: str wheels: list[Path] = [] missing: list[str] = [] for subdir, plat_tags in PLATFORMS.items(): - whl = build_wheel(name, version, summary, requires_python, subdir, plat_tags) + whl = build_wheel(name, version, summary, requires_python, readme, subdir, plat_tags) if whl: wheels.append(whl) else: @@ -623,7 +635,7 @@ def main() -> None: ) args = parser.parse_args() - name, version, summary, requires_python = read_project_meta() + name, version, summary, requires_python, readme = read_project_meta() if args.upload_only: log(f"Publishing {name} {version} (upload-only, reusing dist/wheels/)") @@ -663,7 +675,7 @@ def main() -> None: download_artifacts(repo, run_id) # Step 4: Build platform wheels - wheels = build_all_wheels(name, version, summary, requires_python) + wheels = build_all_wheels(name, version, summary, requires_python, readme) # Step 5: Upload if args.dry_run: diff --git a/pyproject.toml b/pyproject.toml index a629e8f7..d55e98ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ name = "fbuild" version = "2.1.21" description = "PlatformIO-compatible embedded build tool (Rust implementation)" +readme = "README.md" requires-python = ">=3.10" dependencies = ["zccache>=1.2.13"]