Skip to content

fix(AI-3750): stop duplicate _ui_dist wheel entry (hatchling >= 1.30) - #623

Merged
padak merged 5 commits into
mainfrom
martinvasko-ai-3750-ai-kit-docs-misroute-kbagent-install-points-at-old-kbc-cli
Aug 23, 2026
Merged

fix(AI-3750): stop duplicate _ui_dist wheel entry (hatchling >= 1.30)#623
padak merged 5 commits into
mainfrom
martinvasko-ai-3750-ai-kit-docs-misroute-kbagent-install-points-at-old-kbc-cli

Conversation

@Matovidlo

@Matovidlo Matovidlo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes a wheel-build crash ("A second file is being added to the wheel archive at the same path") that aborts uv build --wheel whenever src/keboola_agent_cli/_ui_dist/ is populated at collection time.
  • Root cause (revised after review — see below): a directory-shaped force-include collides with packages-based file selection from hatchling 1.30.0 onwards. requires = ["hatchling"] is unpinned, so every build takes the latest. This is not tied to VCS state: it reproduces on a clean checkout of main with both .git and .gitignore present.
  • Fix: add an explicit exclude = ["src/keboola_agent_cli/_ui_dist"] under [tool.hatch.build.targets.wheel] — a plain glob evaluated unconditionally, so force-include is the only path that adds it. Verified version-independent.
  • Gives the sdist target the symmetric guard, so generated SPA output can never ship as source.
  • Adds an end-to-end regression test (tests/test_build_hook.py::TestForceIncludeNoDuplicate) that builds a real wheel; verified it fails on the pre-fix config and passes on the post-fix one.
  • Documents the previously-undocumented Python >=3.12 install floor in README's Install section, with the UV_PYTHON workaround for when uv doesn't auto-fetch a matching interpreter.

Root cause: what changed and why

This PR originally attributed the failure to a missing .git directory and git check-ignore. Three successive explanations were wrong, and the record matters because the comments in pyproject.toml are aimed at whoever next edits this build config:

Explanation Verdict
Needs a missing .git dir; hatchling runs git check-ignore Wrong — hatchling never shells out to git
Needs no reachable .gitignore, notably an sdist (its include omits .gitignore) Wrong — hatchling force-includes the located VCS exclusion file, so .gitignore is in the tarball (caught by @padak)
Any source tree where no .gitignore is reachable Wrong — an ordinary checkout with both files present fails too

Bisected instead, on a clean clone of main with .git and .gitignore present, a prebuilt SPA on disk and the wheel exclude removed:

hatchling result
1.27.0 / 1.28.0 / 1.29.0 builds OK
1.30.0 / 1.31.0 / 1.32.0 FAILS (duplicate entry)

On 1.27.0, deleting .gitignore outright still builds fine — which rules the gitignore mechanism out as the explanation entirely. Consequence: this is not a latent edge case. Any wheel build with a prebuilt SPA on current hatchling hits it, including the ordinary release path.

Filed against AI-3750. Note: this PR covers only Defects 2 & 3 from that issue (the wheel-build crash and the undocumented Python floor). Defect 1 (ai-kit's docs pointing at the wrong CLI / developers.keboola.com/cli) lives entirely in the separate keboola/ai-kit repo — nothing in this repo references it, so there's nothing to fix here for that part.

Test plan

  • Reproduced the reported error and bisected it across hatchling 1.27 → 1.32 (table above).
  • Applied the exclude fix and confirmed exactly one _ui_dist/index.html on hatchling 1.27, 1.29, 1.30 and 1.32 — the fix is version-independent.
  • py.typed verified present exactly once (file-shaped force-includes were never affected: hatchling reserves their distribution path by exact match, which is why only the directory-shaped entry broke).
  • sdist→wheel (no .gitignore in the tarball) builds with one _ui_dist/index.html carrying fresh content — _bundle_ui rmtree's the dir first, so a stale copy can never reach a wheel.
  • sdist no longer carries _ui_dist/; reproduced the leak before the sdist guard and its absence after.
  • New test TestForceIncludeNoDuplicate::test_wheel_build_does_not_duplicate_ui_dist; fails with the fix reverted both under a normal TMPDIR and under a TMPDIR beneath an ancestor .gitignore (the fixture's .git boundary is what makes that second case fail rather than pass vacuously).
  • ruff check / ruff format --check clean; typecheck, loc-check, version-check, command-sync-check green.
  • Full test suite: 5692 passed, 172 skipped.

Known, pre-existing, not caused by this PR

  • Branch is behind main (now 0.89.0; branch base 0.86.0). main does not carry this fix. A test merge is clean — no conflicts.
  • make changelog-check fails here and identically on main at this base: it queries live GitHub releases and finds no changelog.py entries for 0.87.0 / 0.88.0 / 0.89.0. Expect CI red on that gate until the branch picks up main.

Related issues

AI-3750

🤖 Generated with Claude Code

…builds

hatchling's force-include for _ui_dist/ relies on .gitignore-based exclusion
to avoid double-adding the path, but that exclusion needs a .git directory to
run `git check-ignore` against. A git+ install that hands hatchling a plain
exported tree (no .git) skips it, so _ui_dist/index.html gets added twice and
the build aborts. Add an explicit wheel-target exclude so it works regardless
of VCS state, and document the undocumented Python >=3.12 floor in the README
install instructions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown

AI-3750

@Matovidlo

Copy link
Copy Markdown
Contributor Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a wheel-build failure that occurs when building from a VCS-less exported source tree (no .git directory), where src/keboola_agent_cli/_ui_dist/ could be collected twice (default package collection + force-include) and abort the build with a duplicate-archive-path error. It also adds a regression test to exercise that no-.git build path and documents the project’s Python version floor in the install instructions.

Changes:

  • Prevent duplicate _ui_dist/ inclusion by explicitly excluding src/keboola_agent_cli/_ui_dist from default wheel collection while keeping force-include as the single inclusion path.
  • Add an end-to-end test that builds a wheel in a .git-less temporary project and asserts _ui_dist/index.html appears exactly once.
  • Document the Python >= 3.12 requirement (and UV_PYTHON=3.12 workaround) in the README install section.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
pyproject.toml Adds an explicit wheel exclude to avoid duplicate _ui_dist/ entries in VCS-less builds while retaining force-include.
tests/test_build_hook.py Adds an end-to-end regression test that builds a wheel without a .git directory and checks _ui_dist isn’t duplicated.
README.md Documents Python >= 3.12 install requirement and a uv interpreter selection workaround.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/test_build_hook.py
@Matovidlo

Copy link
Copy Markdown
Contributor Author

Closing -- scope was wrong for this issue; redoing the actual fix (ai-kit docs cross-references) in keboola/ai-kit instead.

@Matovidlo Matovidlo closed this Aug 20, 2026
@Matovidlo Matovidlo reopened this Aug 20, 2026
@Matovidlo
Matovidlo requested a review from MiroCillik August 20, 2026 09:44
@Matovidlo
Matovidlo marked this pull request as ready for review August 20, 2026 09:44
@Matovidlo
Matovidlo requested a review from soustruh August 20, 2026 09:44
Comment thread tests/test_build_hook.py Outdated
Comment thread tests/test_build_hook.py Outdated
Miro flagged that AI-3750 references in pyproject.toml/test docstrings
aren't publicly accessible; the technical description stands on its own
without them.

@MiroCillik MiroCillik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 557ad04. The fix itself is sound and load-bearing — I reproduced the original failure by stripping the new exclude line and building the same fixture tree (ValueError: A second file is being added to the wheel archive at the same path: keboola_agent_cli/_ui_dist/index.html), and confirmed against hatchling 1.32 source that recurse_forced_files() applies no include/exclude filtering, so the new exclude cannot suppress the force-include. Also confirmed packages being set short-circuits default_file_selection_options, so exclude_spec's eager self.default_exclude() call has no side effect. The new test is a genuine regression test.

Four findings below — one is a docs bug that makes the documented workaround inert, two are about the root cause being misattributed (which also makes the regression test able to pass vacuously), one is test hygiene. Nothing blocking the one-line fix.

Thanks for dropping the AI-3750 refs in 557ad04 — that was on my list and it's already handled.

Comment thread README.md Outdated
Comment thread pyproject.toml Outdated
Comment thread tests/test_build_hook.py Outdated
Comment thread tests/test_build_hook.py
@Matovidlo
Matovidlo requested a review from MiroCillik August 21, 2026 13:50
…d regression test

Follow-up on review of #623. Four issues, none in the one-line `exclude`
fix itself (which is load-bearing and verified).

- README: `UV_PYTHON=3.12 curl ... | sh` assigns the var to `curl`, not
  `sh`, so neither the script nor the `uv` it invokes ever saw it -- the
  documented workaround for `does not satisfy Python>=3.12` was inert.
  Show the `| UV_PYTHON=3.12 sh` and `export` forms instead.

- pyproject/test docstrings: the root cause was misattributed. Hatchling
  never runs `git check-ignore`; it parses the `.gitignore` *file* found by
  `locate_file(root, ".gitignore", boundary=".git")`, where `.git` is the
  boundary that STOPS the upward search rather than a prerequisite. A
  `git+` install ships a tracked `.gitignore` and excludes fine; the shape
  that actually failed is the sdist, whose `include` list omits it.

- The regression test asserted "no `.git`", which guards the wrong
  invariant: with neither `.git` nor `.gitignore` in the fixture, hatchling
  searched every ancestor of `tmp_path`, so a `TMPDIR` under any checkout
  let an ancestor `.gitignore` supply the exclusion and the test passed
  with the fix reverted. Create an empty `.git` (the boundary) and assert
  no local `.gitignore`. Verified: reverting `exclude` now fails both with
  a normal TMPDIR and with TMPDIR beneath an ancestor `.gitignore`.

- The module inherited `KBAGENT_SKIP_UI_BUILD` (exported in parts of CI),
  which makes `_bundle_ui` ship an empty `_ui_dist/`; 5 tests then failed
  on a green build with messages pointing elsewhere. Clear it in an autouse
  fixture. Also raise the wheel-build timeout to 300s for cold-cache
  Windows runners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@MiroCillik MiroCillik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, at 354ee50. My four earlier findings are addressed (replies in each thread). Re-verified from scratch: removing the exclude line still reproduces ValueError: A second file is being added to the wheel archive at the same path, hatchling's recurse_forced_files is not subject to exclude, the sibling py.typed force-include does not collide (set_build_data reserves file-shaped force-include paths by exact match — which is exactly why the directory-shaped _ui_dist entry was the one that broke, so the root cause holds), and the pattern also covers nested SPA output (_ui_dist/assets/deep/c.css). 21 tests pass, ruff clean.

No correctness bug in the fix. Everything below is low severity, and three of them are against my own follow-up commit.

Two that can't be anchored inline (files/lines outside this diff):

  1. scripts/hatch_build.py:18 — step 3 of the module docstring now states the opposite of reality: it says the dist is copied into _ui_dist/ "so hatchling's normal package collection picks it up (the dir is in .gitignore …)". After this PR normal collection explicitly skips _ui_dist and force-include is the only way in. A maintainer trusting that sentence could drop the force-include entry and ship a UI-less wheel — only the Windows check_wheel_ui --expect-ui step would catch it. Worth updating in this PR since this PR is what made it false.

  2. pyproject.toml:92 (sdist exclude) — the sdist target has no symmetric guard, and it does leak. I reproduced it: include has src/, so with _ui_dist/ already populated on disk (an earlier uv pip install -e .) and no reachable .gitignore, uv build --sdist ships keboola_cli-0.86.0/src/keboola_agent_cli/_ui_dist/index.html — a stale prebuilt SPA baked into the source distribution. Worth noting the hook is wheel-scoped, so an sdist build never creates _ui_dist itself; a clean tree is unaffected (verified both ways). Not a crash, but adding "src/keboola_agent_cli/_ui_dist" to the sdist exclude makes both targets VCS-state-independent for exactly the reason this PR gives for the wheel.

Process note: this lands without a version bump or changelog.py entry, so install.sh's preferred prebuilt-wheel path keeps serving v0.86.0 — whose sdist→wheel build is still broken. Only the git+ fallback benefits until the next release. Fine if a release is imminent; worth a deliberate decision if not.

Comment thread tests/test_build_hook.py Outdated
Comment thread tests/test_build_hook.py Outdated
Comment thread README.md Outdated
Comment thread pyproject.toml
@padak

padak commented Aug 22, 2026

Copy link
Copy Markdown
Member

@Matovidlo quick check before we act on this: you commented "Closing — scope was wrong for this issue" on 2026-08-20, but the PR stayed open and @MiroCillik has since done two thorough review passes (reproducing the bug and verifying the fix against hatchling internals, all findings addressed). The duplicate _ui_dist wheel-entry bug is still present on current main, so the fix itself looks wanted regardless of the broader AI-3750 docs scope being handled in keboola/ai-kit.

Are you OK with merging this PR as-is (decoupled from AI-3750)? We would like to include it in the upcoming 0.89.0 release. If you'd rather close it, say so and we'll take the pyproject.toml exclude + regression test forward in a separate PR with you as co-author.

@padak padak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-opinion review (OpenAI Codex, gpt-5.6-sol, high reasoning) — verdict: MERGE WITH NITS.

No blocking correctness or security issues found. The hatchling fix is sound: normal package selection honors exclude, while force-include is processed separately and still adds _ui_dist exactly once.

Validation performed by the reviewer:

  • Confirmed the pre-fix configuration reproduces the duplicate-entry exception.
  • Confirmed the patched wheel contains one _ui_dist/index.html and one py.typed.
  • Confirmed wheel-from-sdist, editable, and empty-UI builds succeed; the wheel-only exclusion does not alter sdist contents.
  • _ui_dist absence remains handled by the build hook creating an empty directory.

Nits (both overlap with @MiroCillik's open threads):

  1. LOW — README.md:17: UV_PYTHON=3.12 cannot solve an offline/download-disabled installation unless Python 3.12 is already installed or cached. Clarify: install Python 3.12 first, then use UV_PYTHON=3.12 if uv does not select it automatically.
  2. LOW — tests/test_build_hook.py:329: test_wheel_builds_without_git_directory actually creates an empty .git directory to stop hatchling's upward .gitignore search. Rename to something like test_wheel_builds_without_reachable_gitignore; hatchling does not invoke git check-ignore.

@padak padak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent verification pass (fresh worktree, hatchling 1.32.0 source + empirical builds — no reliance on prior review threads). Verdict: TAKE WITH FIXES. The one-line exclude is correct, minimal and zero-regression; reproduced the failure on unfixed v0.89.0 main and confirmed the fix end-to-end, including a merged-tree build (git merge-tree is clean, 29 commits behind but no conflict, no version regression — the branch never touches the version line). Test is a genuine guard: fails pre-fix, passes post-fix, and the .git boundary fixture is load-bearing (removing it makes the test pass vacuously via an ancestor .gitignore, exactly as @MiroCillik found).

Two findings that are new to this thread:

  1. The corrected root-cause comment is wrong about the sdist. pyproject.toml (and two docstrings in tests/test_build_hook.py) now claim the failing shape is "notably an sdist build, because [tool.hatch.build.targets.sdist].include does not ship .gitignore". Hatchling's SdistBuilder.get_default_build_data force-includes the located VCS exclusion files regardless of the include list — verified end-to-end on unfixed main: uv build --sdist puts .gitignore in the tarball, and sdist→wheel builds fine. The accurate framing: any source tree where no .gitignore is reachable — an export/vendored copy with dotfiles stripped, or a .git boundary with .gitignore removed. This is worth fixing before merge; it replaced one wrong explanation with another, aimed at exactly the maintainer who next edits the sdist include list.

  2. No shipped install path currently hits the bug (prebuilt wheel ✓, install.sh git+ fallback ✓ — uv clones tracked files incl. .gitignore, release workflow ✓, sdist→wheel ✓ per above). So this is a latent-robustness fix, not a user-facing incident — which argues for correcting the justification, not for dropping the fix.

Also worth doing before a squash merge: the PR title and body still carry the refuted explanation ("VCS-less source builds", "uv tool install git+… on a source tree with no .git", "git check-ignore") and would become the permanent commit message on main; and the open nit about renaming test_wheel_builds_without_git_directorytest_wheel_builds_without_reachable_gitignore guards a proven false-negative trap, so it is worth taking.

@Matovidlo — the fix stands on its own merits for this repo regardless of the AI-3750 scope moving to ai-kit; happy to carry the comment/title corrections into a follow-up commit on this branch if you prefer, or take the whole thing forward in a fresh PR with you as co-author. Just say which.

…e test name

Second review round on #623. All five findings were reproduced before fixing.

- scripts/hatch_build.py: step 3 of the module docstring said the dist is
  copied into `_ui_dist/` "so hatchling's normal package collection picks
  it up". This PR makes that false -- collection now explicitly skips the
  dir and `force-include` is the only path in. Someone trusting the old
  wording could drop either half and ship a UI-less wheel or reintroduce
  the duplicate; only the Windows `check_wheel_ui --expect-ui` step catches
  the former.

- pyproject.toml: give the sdist the symmetric guard. Reproduced the leak:
  `include` has `src/`, so with `_ui_dist/` already on disk (an earlier
  editable install) and no reachable `.gitignore`, `uv build --sdist` ships
  src/keboola_agent_cli/_ui_dist/index.html -- generated assets as source.
  A clean tree was never affected: the hook is wheel-scoped, so an sdist
  build never creates the dir. Downstream impact is nil either way because
  `_bundle_ui` rmtree's `_ui_dist` before every build, so a stale copy
  cannot reach a wheel; this is sdist hygiene, not a correctness fix.

- tests: `test_wheel_builds_without_git_directory` now deliberately CREATES
  `.git` (the boundary that stops hatchling's upward `.gitignore` search),
  so the name said the opposite of the body and invited someone to "fix"
  the body into a false negative. Renamed to match what it covers.

- tests: the autouse fixture's docstring claimed KBAGENT_SKIP_UI_BUILD is
  "exported by parts of CI". It is not -- ci.yml scopes it to one step's
  `env:`, the only occurrence in .github/ or the Makefile. The fixture
  still earns its place (a developer's exported shell flag breaks 5 tests
  here, measured); only the rationale was wrong.

- README: the workaround hard-pinned `UV_PYTHON=3.12` while requires-python
  is >=3.12, so on a 3.13-only machine with downloads disabled -- exactly
  the stated precondition -- it failed where no pin would have worked.

Verified end to end: sdist no longer carries `_ui_dist`; sdist -> wheel (the
originally broken shape, no `.gitignore` in the tarball) builds with exactly
one `_ui_dist/index.html` carrying fresh content; reverting the wheel
`exclude` still fails the regression test both with a normal TMPDIR and with
TMPDIR beneath an ancestor `.gitignore`. Full suite 5692 passed, 172 skipped;
every `make check` gate passes except the pre-existing `changelog-check`
staleness, which fails identically on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MiroCillik

Copy link
Copy Markdown
Member

Both body-only findings from the last round are fixed in 401fe80, plus one thing the PR should know about before merge.

scripts/hatch_build.py — step 3 of the module docstring now describes the post-PR reality: _ui_dist/ reaches the wheel only via force-include, because the wheel target deliberately excludes it from normal package collection, and dropping either half breaks a build in a different way.

pyproject.toml sdist exclude — added src/keboola_agent_cli/_ui_dist. One correction to what I wrote last round: the downstream impact is nil, not merely "not a crash". _bundle_ui rmtrees _ui_dist before every build, so a leaked copy can never reach a wheel — I confirmed this by building a wheel from a tarball carrying a STALE marker and getting fresh content out. So this is sdist hygiene (generated bytes shipped as source), not a correctness fix. Reproduced the leak before and its absence after.

Verified end to end: sdist no longer carries _ui_dist; sdist→wheel (the originally broken shape — no .gitignore in the tarball) builds with exactly one _ui_dist/index.html; full suite 5692 passed / 172 skipped; every make check gate green except as noted below.


Two things for a human to decide — both pre-existing, neither caused by these commits:

  1. This branch is 33 commits behind main, which is now at 0.89.0 (branch: 0.86.0). main does not carry this fix, so the PR is still needed and not redundant. A test merge is clean — no conflicts.

  2. make changelog-check fails on this branch, and identically on main at the same base: it fetches live GitHub releases via gh release list and compares them to changelog.py, so it reports missing entries for 0.87.0 / 0.88.0 / 0.89.0. Expect CI to be red for that reason until the branch picks up main.

That also corrects my process note from the last round: I said install.sh keeps serving v0.86.0. The current release is v0.89.0 — the point stands (every released wheel still lacks this fix, so only the git+ fallback benefits until the next release), but the version I named was stale.

@padak's second review was right that the sdist justification is false --
hatchling force-includes the located VCS exclusion file, so `.gitignore`
IS in the tarball (verified: keboola_cli-0.86.0/.gitignore). But his
replacement framing, and the original `.git`/`git check-ignore` one, and
my `no reachable .gitignore` one are all wrong too. Bisected instead of
reasoned:

  clean clone of main, `.git` AND `.gitignore` present, prebuilt SPA on
  disk, wheel `exclude` removed:
    hatchling 1.27.0  builds OK
    hatchling 1.28.0  builds OK
    hatchling 1.29.0  builds OK
    hatchling 1.30.0  FAILS (duplicate)
    hatchling 1.31.0  FAILS
    hatchling 1.32.0  FAILS

So VCS state is not the trigger: a directory-shaped `force-include`
collides with `packages` collection whenever `_ui_dist/` exists at
collection time, from hatchling 1.30.0 on, and `requires = ["hatchling"]`
is unpinned. On 1.27.0 deleting `.gitignore` outright still builds fine,
which rules the gitignore mechanism out as the explanation entirely.

Corrected in all four places that carried a mechanism claim: the wheel
`exclude` comment, the sdist `exclude` comment, the test module docstring
and the class docstring. The comments now say what was measured and warn
against relying on hatchling's gitignore exclusion, which is
version- and layout-dependent (an ancestor `.gitignore` suppresses the
duplicate in a minimal fixture; this repo's own root `.gitignore` does
not). `scripts/hatch_build.py` needed no change -- its wording was
already version-neutral.

Renamed the test once more: `test_wheel_build_does_not_duplicate_ui_dist`,
which states the invariant rather than a mechanism that turned out to be
the wrong one. The `.git` boundary in the fixture stays and is still
load-bearing for hermeticity (re-verified: removing it lets an ancestor
`.gitignore` pass the test with the fix reverted).

Also verified the fix is version-independent: exactly one
`_ui_dist/index.html` on hatchling 1.27, 1.29, 1.30 and 1.32.

Full suite 5692 passed, 172 skipped; ruff, typecheck, loc/version/
command-sync gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MiroCillik MiroCillik changed the title fix(AI-3750): stop duplicate _ui_dist wheel entry on VCS-less source builds fix(AI-3750): stop duplicate _ui_dist wheel entry (hatchling >= 1.30) Aug 23, 2026
@MiroCillik

Copy link
Copy Markdown
Member

@padak — your finding 1 is fixed in 134d1ee, and it turned out to reach further than either of us had it. Also a correction to my own reply above: I told you your framing was right. It isn't, and neither were my two.

You were right about the mechanism you tested. Hatchling force-includes the located VCS exclusion file, so .gitignore is in the tarball — verified, keboola_cli-0.86.0/.gitignore. My "the sdist include list does not ship .gitignore" was false.

But any source tree where no .gitignore is reachable is also wrong. I stopped reasoning and bisected. Clean clone of main, .git and .gitignore both present, prebuilt SPA on disk, wheel exclude removed:

hatchling result
1.27.0 / 1.28.0 / 1.29.0 builds OK
1.30.0 / 1.31.0 / 1.32.0 FAILS (duplicate entry)

And on 1.27.0, deleting .gitignore outright still builds fine — so the gitignore mechanism was never the explanation, in any of its three tellings. What actually changed is how hatchling ≥ 1.30 treats a directory-shaped force-include against packages-based selection: the collision now happens whenever _ui_dist/ exists at collection time. requires = ["hatchling"] is unpinned, so every build takes the latest.

That flips your finding 2. "No shipped install path currently hits the bug" doesn't hold: a clean clone of main, untouched, with a prebuilt SPA fails on current hatchling — the ordinary release path. Not latent robustness; the current default build. (Your own "reproduced the failure on unfixed v0.89.0 main" agrees; we only differed on scope.)

Fixed in 134d1ee:

  • Wheel exclude comment, sdist exclude comment, test module docstring and class docstring all rewritten to state what was measured, and to warn against relying on hatchling's gitignore exclusion at all — it is version- and layout-dependent (an ancestor .gitignore suppresses the duplicate in a minimal fixture; this repo's own root .gitignore does not).
  • scripts/hatch_build.py needed no change; its wording was already version-neutral.
  • Test renamed once more to test_wheel_build_does_not_duplicate_ui_dist — it states the invariant instead of a mechanism that keeps turning out to be the wrong one. Your rename request stands satisfied; the .git boundary in the fixture stays and is still load-bearing for hermeticity (re-verified: remove it and an ancestor .gitignore passes the test with the fix reverted).
  • PR title and body updated, since you flagged they'd become the squash commit message. Both now carry the bisected root cause and a table of the three refuted explanations.
  • Verified the fix is version-independent: exactly one _ui_dist/index.html on hatchling 1.27, 1.29, 1.30 and 1.32.

Your review-1 nits were already fixed in 401fe80 (README no longer hard-pins UV_PYTHON=3.12 — it says pin a 3.12-or-newer interpreter you actually have; test renamed).

Two things I did not touch, both pre-existing: the branch is behind main (clean test merge, no conflicts, and main does not carry this fix), and make changelog-check fails here exactly as it does on main at this base — it queries live GitHub releases and finds no changelog.py entries for 0.87.0–0.89.0. Both noted in the PR body.

@padak

padak commented Aug 23, 2026

Copy link
Copy Markdown
Member

@MiroCillik — the bisect settles it, and it corrects my finding 2 as well: I claimed "no shipped install path currently hits the bug" after testing the shipped paths against the gitignore-reachability hypothesis — which your version table shows was never the operative variable. With hatchling unpinned and the collision keyed on _ui_dist/ existing at collection time on ≥ 1.30, the ordinary rebuild path (any tree where a previous build or serve --ui left _ui_dist/ populated) is exposed today. Consider my finding 2 withdrawn; the fix is a current-default-build fix, not latent robustness.

I re-checked my review-1 asks against 134d1ee: root-cause comments rewritten to what was measured (with the version-dependence warning), PR title/body now carry the bisected cause plus the table of refuted explanations — so nothing from my review remains open, and the squash message hazard is gone. The invariant-based test name is the right call; three refuted mechanism-names in one review cycle is the strongest possible argument for naming the invariant instead.

One suggestion for a follow-up (not this PR): requires = ["hatchling"] being unpinned is what turned an upstream behavior change into a surprise build break — worth considering a lower-bound-plus-cap pin or at least a comment in [build-system] noting the ≥ 1.30 semantics this config depends on.

From my side this PR is ready. @Matovidlo — with the root cause now bisected, all review threads addressed, and the title/body rewritten, the only thing missing is your word per your "Closing — scope was wrong" comment: OK to merge as-is (decoupled from AI-3750)?

@padak padak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All CI green (check, test 3.12/3.13, Windows wheel build). Clean test-merge against main -- no conflicts. No version bump and no changelog.py entry, matching the release process: the fix will be batched into the next release PR. All five open review threads carry a verified fix commit and have been resolved.

The root cause holds up on re-read: hatchling has two independent paths that can add a file to the wheel (packages collection and force-include), and from 1.30.0 the .gitignore-based exclusion no longer suppresses the first one. Since 'requires = ["hatchling"]' is unpinned, this is not a latent edge case -- it hits the ordinary release path. The unconditional 'exclude' glob is the right shape because recurse_forced_files() applies no include/exclude filtering, so force-include stays intact.

@padak
padak merged commit 1860a86 into main Aug 23, 2026
4 checks passed
@padak
padak deleted the martinvasko-ai-3750-ai-kit-docs-misroute-kbagent-install-points-at-old-kbc-cli branch August 23, 2026 21:05
@padak padak mentioned this pull request Aug 23, 2026
10 tasks
padak added a commit that referenced this pull request Aug 23, 2026
* chore(release): 0.90.0

Bumps pyproject.toml to 0.90.0 and adds the changelog entry covering every
PR merged since v0.89.0 (#658, #662, #661, #663, #665, #666, #664, #668,
#667, #623), resolves the vNEXT placeholders those PRs left behind, and
adds the curated What's new reel for the release.

* docs(web-server): keep the What's-new anchor stable across releases

The '### What's-new popup *(since vNEXT)*' heading put the version gate in
the heading itself, so resolving the placeholder to 0.90.0 changed the
generated slug to 'whats-new-popup-since-0900' and broke the in-page link
at line 138 -- and would have broken it again on every future release.

Moved the '(since 0.90.0)' tag to the first body line: the anchor is now
the stable 'whats-new-popup', the gate stays visible, and
check_version_gates.py still sees it (it scans the whole file, not just
headings).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants