Repository navigation
bump-leg.py: a committed workflow file under .github/workflows/ or a contracts/<role>-pin.yaml that is not UTF-8 ends in a UnicodeDecodeError traceback, exit 1, with no refusal naming the file, so the operator reads Python's decode error and --dry-run dies the same way #289
Description
(lane openRepoShape-2, 2026-10-12T00:11:22Z)
Found by this lane's adversarial review of PR #264 at a8d31ee (the fix for #253), its fix-list item 6. It is the same on main d94d155, where it was reproduced again today: #264 neither causes it nor changes it, and #253's rollback promise does not reach it, because the crash comes before the leg moves. Not claimed.
What is wrong. bump-leg.py reads the leg's contracts/<role>-pin.yaml and every .yml or .yaml file under .github/workflows/ with read_text(encoding="utf-8"), and nothing on the path catches a UnicodeDecodeError. main() catches Refusal and CommandFailed only, so a committed file that is not UTF-8 (a Latin-1 # café comment, a UTF-16 file with a byte-order mark) ends the run in a Python traceback, exit 1. The operator gets no refusal that names the file, and --dry-run fails the same way, because both reads run before its early return. Nothing has moved: the working tree, the index, the pin, the root's HEAD and the leg's HEAD are as they were. (The leg's object store was already fetched into, which a dry run documents.)
# $U: a checkout of openRepoShape at d94d155. Every repository is local (--local-remote-dir); no network.
python3 "$U/scaffold-project.py" --org testorg --project Northwind --elected-by 'T H' --elected-on 2026-09-02 \
--local-remote-dir "$R/remotes" --work-dir "$R/work"
git -c protocol.file.allow=always clone -q --recurse-submodules "$R/remotes/Northwind.git" "$C"
git clone -q "$R/remotes/Northwind-spec.git" "$R/adv" # one new commit on the spec leg, pushed
( cd "$R/adv" && printf '# new\n' > NEW.md && git add NEW.md && git commit -qm advance && git push -q origin HEAD:refs/heads/adv )
NEW=$(git -C "$R/adv" rev-parse HEAD)
cd "$C" && git checkout -q -b bump/spec
# a workflow that names both legs, committed (so the tracked-files rule of #207 is met), then a Latin-1 byte in it, committed
SPEC=$(git rev-parse HEAD:spec); CODE=$(git rev-parse HEAD:code)
printf 'on: [pull_request]\njobs:\n s:\n uses: testorg/Northwind-spec/.github/workflows/x.yml@%s\n c:\n uses: testorg/Northwind-code/.github/workflows/y.yml@%s\n' "$SPEC" "$CODE" > .github/workflows/legs.yml
git add .github/workflows/legs.yml && git commit -qm workflow
printf '# caf\xe9\n' >> .github/workflows/legs.yml && git commit -qam 'latin-1 comment'
python3 "$U/scripts/bump-leg.py" --root "$C" --leg spec --to "$NEW" --local-remote-dir "$R/remotes"
# exit 1
# Traceback (most recent call last):
# bump-leg.py line 885, in <module> / line 875, in main / line 788, in cmd_bump / line 305, in plan_workflows
# text = path.read_text(encoding="utf-8")
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 236: invalid continuation byte
python3 "$U/scripts/bump-leg.py" --root "$C" --leg spec --to "$NEW" --local-remote-dir "$R/remotes" --dry-run # the same, exit 1
git status --porcelain # empty; root HEAD and spec/ HEAD unchanged| the root holds, committed | the run | result on d94d155 |
|---|---|---|
.github/workflows/legs.yml with # caf\xe9 appended |
bump | exit 1, UnicodeDecodeError (0xe9) from plan_workflows :305, called at :788; tree clean |
| the same | --dry-run |
exit 1, the same traceback; tree clean |
.github/workflows/other.yml saved as UTF-16 (a file naming no leg at all) |
bump | exit 1, UnicodeDecodeError (0xff in position 0) from :305; tree clean |
contracts/spec-pin.yaml with # caf\xe9 appended |
bump | exit 1, UnicodeDecodeError (0xe9 in position 1884) from cmd_bump :787; tree clean |
| the same | --dry-run |
exit 1, the same traceback; tree clean |
contracts/code-pin.yaml (the OTHER leg's pin) with # caf\xe9 appended |
bump | exit 2, REFUSED bump-leg-validators-red, wrapping the project validator's own REFUSED yaml-unreadable: ...: not UTF-8 at byte 1884; rolled back, tree clean (the shape this fix should give the first five rows) |
| nothing non-UTF-8 (control) | bump | exit 0 |
The project's own gate is no better for a workflow file: on the Latin-1 tree, python3 scripts/validate-pins.py --root "$C" dies with the same traceback at its own :111 (_workflow_refs), exit 1, the code a validator uses for a finding. For a non-UTF-8 pin it refuses cleanly (REFUSED yaml-unreadable, exit 2).
Why. plan_workflows (scripts/bump-leg.py:255) walks .github/workflows/ and reads each .yml and .yaml file with path.read_text(encoding="utf-8") (:305), whatever it contains, before it knows whether the file names the leg; cmd_bump reads the pin the same way (:787) and then calls plan_workflows (:788). Both come before if args.dry_run: (:803). Neither read sits in a handler, and main() (:869-881) catches Refusal (:876) and CommandFailed (:879) only. UnicodeDecodeError is a ValueError, so no OSError clause would see it either. load_yaml (scripts/repo_shape.py:732-756) already turns it into Refusal("yaml-unreadable", "<path>: not UTF-8 at byte N ...") for every other reader (#192); the pin read at :787 goes around load_yaml, and the workflow read has no counterpart.
What it should do.
- Refuse by name, before anything is written, exit 2, under
--dry-runas well. For a workflow file, a refusal of its own (for examplebump-leg-workflow-unreadable) naming the path relative to the root, the byte offset and the reason, in the wordsload_yamluses. For the pin, the same withbump-leg-pin-unreadable, or letload_yaml'syaml-unreadablethrough; either way it must namecontracts/<role>-pin.yaml. - Refuse, do not read lossily.
errors="replace"would let the run proceed, but the rewritten text is written back (write_lf), so the lockstep commit would carry U+FFFD where the operator's byte was. The remediation says to re-save the file as UTF-8, commit it, and run the same command again. Skipping the file is no better:validate-pins.pyreads every workflow file on disk and cannot read it either. - The refusal covers the file whether or not it names the leg, since
plan_workflowsalready reads every file in the directory (the UTF-16 row above names no leg and still stops the bump). Whether to read only after a cheap filter is the author's choice; it must not become a silent skip.
How to test. In tests/test_bump_leg.py, beside test_an_untracked_draft_naming_the_leg_is_refused_by_name_not_committed (:296), with its helpers (reference_both_legs, bump, branched, committed):
- (a) commit a workflow holding
b"# caf\xe9\n"(written withwrite_bytes, so it is portable to Windows): the bump exits 2, stderr holds the refusal id and the file's relative path, noTraceback,git status --porcelainis empty and the leg's HEAD is where it was; - (b) the same with
--dry-run; - (c) a committed
contracts/spec-pin.yamlholding a Latin-1 byte, same assertions; - (d) control: a UTF-8 workflow with a non-ASCII comment still bumps, and the comment survives byte for byte.
Reverting the new handling fails (a) to (c).
Not in this issue. The same non-UTF-8 workflow file ends scripts/validate-pins.py in a traceback at _workflow_refs (templates/assembly-root/scripts/validate-pins.py:111, the unguarded read_text). That file is a pinned template copy, so a fix moves the sha256 that contracts/shape-pin.yaml records for scripts/validate-pins.py in every project, and every project already cut sees it as upstream-changed. It is a separate change with its own cost.
Related: #253 (the rollback gap in _apply_rewrite, which fires after the leg moves, so this is outside it); #192 (closed: the same crash in load_yaml, fixed there); #207 and #222 (018ce0d, the tracked-files filter in plan_workflows whose read is the one at :305); #254 and #280 (other encoding gaps in the tools that run the project's validators).
🤖 Generated with Claude Code
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-2, session 5b7b5f3d-dce7-452d-a33d-ab8537d91db3@Eagle, 2026-10-12T11:00:00Z, for #289
Logged in
lanes/log/openRepoShape-2.mdatc9110a24f4deebf127b3943cf683176edf0b2f5a.The three reads (lane-collision-protocol Rule 1):
bump-leg.py: the unreadable-file tests wrap three lines that ran past…
bump-leg.py: a bump that fails for any reason once the leg has moved,…
Merge origin/main (ab623dd) into fix/bump-leg-unreadable-files, after #…
bump-leg.py: a workflow file that is not UTF-8 is refused with each l…
docs/handbook.html: the refusal list for `bump-leg.py` names a workfl…
bump-leg.py: the test of the other leg's pin looks for the path in th…
Merge origin/main (53bee5b) into fix/setup-help-offline, so the handb…
Merge origin/main (53bee5b: #366 via #399, #390, #357, #289 via #352)…
Merge origin/main dd8d0ea into fix/adopt-root-kept-symlinks: #261's `…
setup.sh: `--help` from anywhere but a checkout is the shim's own few…