I found three bugs in an open-source dependency on Tuesday morning. By Tuesday evening I had filed three issues, opened three PRs, and was back to working on our actual codebase. The project was aspect-build/rules_py — the Bazel rules for Python that our entire monorepo depends on.
This is less about the bugs themselves and more about the workflow — how you go from “this dependency is broken” to “I’ve submitted fixes upstream” efficiently, especially when you’re doing it from an enterprise environment.
The bugs
All three surfaced during the uv.lock migration I wrote about in the previous post. We switched from pip.parse reading requirements.txt to the uv extension reading uv.lock directly. The extension is marked unstable, and we discovered why.
Bug 1: Hashless wheels (#790).
Our internal PyPI registry doesn’t serve PEP 503 hashes. The uv extension expected a hash for every wheel file reference and crashed when one was missing. This is a reasonable assumption for public PyPI but breaks immediately for any private registry that doesn’t implement the full spec.
Fix: Check for hash presence before trying to parse it. Fallback to no integrity check. ~15 lines of Starlark.
Bug 2: Editable packages not filtered (#791).
uv.lock contains entries for editable installs — local packages under development. These packages don’t have wheel URLs because they’re… local. They’re source directories on your machine. The extension was trying to create Bazel repo rules for them anyway, which generated dangling dependency references pointing at non-existent repos.
Fix: Filter out packages where source.editable is set. Skip them during hub population. ~10 lines.
Bug 3: Multi-version collision (#792).
When uv resolves different versions of the same package for different platforms (e.g., numpy==1.26 for Python 3.10 and numpy==2.0 for Python 3.12), the extension generated repo names that could collide. Two different packages landing in the same Bazel repo name.
Fix: Include the version in the repo name to disambiguate. More invasive — ~30 lines across two files, plus tests.
The workflow
Here’s the practical part — what I actually did, step by step.
Separate GitHub identity
Our enterprise monorepo is on one GitHub org. Upstream open-source projects are on another. I use my personal GitHub account (roundhd) for open-source contributions. Enterprise account for work repos. This means:
# Before upstream work
gh auth switch --user roundhd
git config user.email "personal@email.com"
# After, switch back
gh auth switch --user shiyuanzheng_microsoft
This isn’t just cosmetic. Enterprise accounts often have SSO, signing requirements, and compliance policies that don’t apply to upstream contributions. Using a personal account keeps the contribution clean — no questions about IP ownership, no CLA complications from the enterprise identity.
One branch per fix
I forked aspect-build/rules_py to my personal account, then created three branches:
roundhd/rules_py
├── fix/hashless-wheels → PR #793
├── fix/editable-packages → PR #794
└── fix/multi-version-collision → PR #795
Each branch starts from the same upstream commit. Each fix is independent — you can merge any combination of them without conflicts.
This is the critical workflow decision: split your fixes into independent branches. The temptation when you find three bugs in one debugging session is to fix them all on one branch and submit one PR. Don’t.
Why splitting matters
| Aspect | One big PR | Three small PRs |
|---|---|---|
| Review burden | Reviewer has to understand 3 unrelated changes | Each PR is self-contained |
| Merge flexibility | All or nothing | Each can land independently |
| Bisect-ability | If one fix introduces a regression, git bisect finds the combined commit | Regression pinpoints the exact fix |
| Cross-referencing | “This PR fixes three issues” (confusing) | Each PR links to exactly one issue |
| Revert cost | Revert loses all three fixes | Revert only the problematic one |
Maintainers love small, focused PRs. They can review in 5 minutes, not 30. They can merge one while discussing another. They can backport individual fixes to release branches. Every dimension of the maintenance workflow gets easier when changes are atomic.
The issue-first pattern
For each bug, I filed the issue first, then the PR.
#790 (issue) ← #793 (PR) closes it
#791 (issue) ← #794 (PR) closes it
#792 (issue) ← #795 (PR) closes it
Filing issues first has a subtle benefit: the maintainers see the problem description before they see your code. They can triage independently of the fix. If your fix approach is wrong but the issue is real, the issue survives. If someone else has a better fix, they can open a competing PR against the same issue.
It also makes your PR description trivial. “Fixes #790” is often sufficient — the issue has the context, the PR has the code.
The local patch
I couldn’t wait for upstream to merge. Our monorepo needed these fixes now — CI was broken without them. So we carry a local patch.
Bazel has a clean mechanism for this: single_version_override in MODULE.bazel with a patches list.
single_version_override(
module_name = "aspect_rules_py",
patches = [
"//bazel/patches:aspect_rules_py_hashless_wheels.patch",
],
)
The patch file lives in our repo at bazel/patches/aspect_rules_py_hashless_wheels.patch. It combines all three fixes into one patch (since they’re applied together locally) with a comment:
# Patches for aspect_rules_py uv extension
# Upstream issues: #790, #791, #792
# TODO: Remove after upstream merges #793, #794, #795
The TODO with upstream PR numbers is important. Without it, the patch lives forever. With it, whoever is updating aspect_rules_py to the next version can check whether those PRs have been merged and drop the patch.
The timeline
| Time | Action |
|---|---|
| 9:00 AM | Hit first bug during uv migration testing |
| 9:30 AM | Hit second bug |
| 10:00 AM | Hit third bug, decided to go upstream |
| 10:30 AM | Forked repo, created three branches |
| 11:00 AM | First fix implemented and tested locally |
| 11:30 AM | All three fixes done |
| 12:00 PM | Filed three issues (#790-792) |
| 12:30 PM | Opened three PRs (#793-795) |
| 1:00 PM | Created local patch, CI green |
| 1:30 PM | Back to actual migration work |
Four and a half hours from “this is broken” to “upstream PRs filed and local CI green.” Most of that time was testing, not coding. The actual code changes were small — maybe 55 lines total across all three fixes. The testing, issue writing, and PR descriptions took longer than the code.
The meta-lesson
Enterprise teams often treat upstream dependencies as immutable. Something’s broken? Work around it. Vendor it. Patch it locally and never look back. The mental model is “we consume open source” not “we participate in open source.”
But contributing upstream is almost always faster than maintaining a permanent workaround. The patch I carry locally is debt — every upstream update, someone has to check if it’s still needed, rebase if the patch context changed, test that it still applies. Three PRs that get merged eliminate that debt permanently.
The math is simple. Upstream fix: 4 hours once, then done forever. Local workaround: 2 hours now, plus 30 minutes every time you update the dependency, forever. The breakeven is the fourth update cycle.
The other thing — and this is less tangible but more important — is that filing good issues and clean PRs builds reputation with maintainers. The next time you hit a bug in the same project, there’s a thread of prior contributions. Your issue gets looked at faster. Your PR gets reviewed sooner. Open source is a relationship, not a transaction.
Three bugs, three issues, three PRs, one day. Split your changes. File issues first. Carry local patches with TODO comments. Switch back to your enterprise account when you’re done. That’s the whole workflow.