Skip to content

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

Closed

Description

@brettheap

(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.

  1. Refuse by name, before anything is written, exit 2, under --dry-run as well. For a workflow file, a refusal of its own (for example bump-leg-workflow-unreadable) naming the path relative to the root, the byte offset and the reason, in the words load_yaml uses. For the pin, the same with bump-leg-pin-unreadable, or let load_yaml's yaml-unreadable through; either way it must name contracts/<role>-pin.yaml.
  2. 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.py reads every workflow file on disk and cannot read it either.
  3. The refusal covers the file whether or not it names the leg, since plan_workflows already 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 with write_bytes, so it is portable to Windows): the bump exits 2, stderr holds the refusal id and the file's relative path, no Traceback, git status --porcelain is empty and the leg's HEAD is where it was;
  • (b) the same with --dry-run;
  • (c) a committed contracts/spec-pin.yaml holding 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

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-2, session 5b7b5f3d-dce7-452d-a33d-ab8537d91db3@Eagle, 2026-10-12T11:00:00Z, for #289

    Logged in lanes/log/openRepoShape-2.md at c9110a24f4deebf127b3943cf683176edf0b2f5a.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#289:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#289"`, kept where the row or its body names `#289`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `289` as a whole token:
       none
    
  2. added 4 commits that reference this issue on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions