Repository navigation
bump-leg.py: a workflow file, a .github/workflows/ directory or a leg pin that this user cannot open ends the run in a PermissionError traceback, exit 1, under --dry-run as well, because cmd_bump and plan_workflows read them with no handler and main() catches Refusal and CommandFailed only, so no refusal names the file or says to make it readable #383
Description
(lane openRepoShape-2, 2026-10-12T14:56Z)
Found by the writer of lane-2 PR #352 (the fix for #289), which refuses a non-UTF-8 file by name and left out the file this tool cannot OPEN as "the same crash, a different cause". Reproduced on origin/main ab623dd, and again on #352's head 107242d (ab623dd with #352 merged), where every row below is the same. Not claimed.
What is wrong. cmd_bump reads the leg's pin with pin_path.read_text(encoding="utf-8") (scripts/bump-leg.py:1088) and then calls plan_workflows (:1089), which lists .github/workflows/ (workflows.iterdir(), :368 and :370) and reads every .yml and .yaml file in it the same way (:385), whether or not the file names the leg. None of the three sits in a handler, and main() (:1170-1183) catches Refusal (:1177) and CommandFailed (:1180) only. A file or a directory this user cannot open raises PermissionError (an OSError) out of read_text or iterdir, and the run ends in a traceback, exit 1, naming Python's error and not a refusal. All three come before the --dry-run early return (:1104), so --dry-run dies the same way. 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).
It is not covered by the neighbouring work. #289 and PR #352 refuse a file that is not UTF-8 (UnicodeDecodeError); an OSError is not that and still reaches main() as a traceback after #352 (run on 107242d, below). #253 (closed) is the WRITE of a workflow file after the leg has moved (_apply_rewrite); this is a READ before anything moves.
How the tree comes to be one bump-leg.py gets this far with. With git's default configuration, chmod 000 on a tracked file changes its ctime, git must re-read the file to compare it, cannot, and reports M: the run is then refused bump-leg-root-dirty (_ensure_root_clean, :626; the refusal, :644), whose remedy says "commit or stash them first", and neither works (git add and git stash fail with error: open(".github/workflows/legs.yml"): Permission denied). The traceback is what the operator gets when git reads the tree as clean: core.trustctime=false with the index refreshed since the file was written, or git update-index --assume-unchanged on the file (both run below); any state in which git does not re-read the file is the same. A .github/workflows/ directory this user cannot list reads as clean too: git status --porcelain prints Permission denied on stderr, nothing on stdout, and exits 0.
# $U: a checkout of openRepoShape at ab623dd. 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
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
git config core.trustctime false; sleep 2; git status --porcelain # one status, so the index is no longer racily clean
chmod 000 .github/workflows/legs.yml
python3 "$U/scripts/bump-leg.py" --root "$C" --leg spec --to "$NEW" --local-remote-dir "$R/remotes"
# exit 1
# Traceback: bump-leg.py line 1176, in main / line 1089, in cmd_bump / line 385, in plan_workflows
# PermissionError: [Errno 13] Permission denied: '<C>/.github/workflows/legs.yml'
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 ab623dd |
|---|---|---|
| nothing unreadable (control) | --dry-run |
exit 0 |
.github/workflows/legs.yml mode 000, core.trustctime=false, index settled |
bump | exit 1, PermissionError from plan_workflows :385, called at :1089; git status empty, leg HEAD unmoved |
| the same | --dry-run |
exit 1, the same traceback; tree clean |
contracts/spec-pin.yaml mode 000, the same git settings |
bump and --dry-run |
exit 1, PermissionError from cmd_bump :1088; tree clean |
.github/workflows/legs.yml mode 000 and git update-index --assume-unchanged on it |
bump | exit 1, PermissionError from :385; tree clean |
.github/workflows/ itself mode 000, the same git settings (or --assume-unchanged on the file) |
bump | exit 1, PermissionError: ... '<C>/.github/workflows' from plan_workflows :368; stdout of git status empty, leg HEAD unmoved |
.github/workflows/legs.yml mode 000, git's default configuration |
bump | exit 2, REFUSED bump-leg-root-dirty listing M .github/workflows/legs.yml; git add on it fails Permission denied |
On #352's head 107242d the same rows end the same way, in the helpers #352 adds: _leg_pin_text :302 for the pin, _workflow_texts :341 for a workflow file, and plan_workflows :462 for the directory. Only the frames moved.
The project's own gate has the same read: python3 scripts/validate-pins.py on the mode-000 tree ends in PermissionError from its _workflow_refs (templates/assembly-root/scripts/validate-pins.py:122, where #359 cites :111, the read's line on a1b3a4b; #359 is the non-UTF-8 half of that).
Why. The reads are bare read_text and iterdir calls, and OSError is not Refusal or CommandFailed. #352 puts the pin read and the workflow walk into _leg_pin_text (:287 on its head) and _workflow_texts (:318) and catches UnicodeDecodeError there; that is the place for the OSError clause, and the reason the refusal must be written there and not left to main() is that --dry-run and the bump both go through them before if args.dry_run:. #352 leaves iterdir() in plan_workflows (:462) outside both helpers, so the directory row needs its own guard there.
What it should do.
- Refuse by name, before anything moves, exit 2, under
--dry-runas well. For a workflow file the refusal bump-leg.py: a workflow file or a leg pin that is not UTF-8 is refused by name before anything moves, so a Latin-1 comment no longer ends the run in a UnicodeDecodeError traceback (#289) #352 introduces for a file that is not UTF-8 (bump-leg-workflow-unreadable), so one id says "this tool cannot read this workflow file" whichever the reason; for the pinbump-leg-pin-unreadable. It names the path relative to the root and the operating system's reason (Permission denied). The remediation says to make the file readable by this user (chmod u+r, or fix who owns it) and run the same command again. - Cover the
OSErrorfamily, notPermissionErroralone: a workflow file that is a directory-turned-file, or vanishes betweeniterdir()and the read, or fails with an I/O error, gets the same refusal. So does a.github/workflows/this user cannot list (the last-but-one row), named as the directory. - The refusal covers the file whether or not it names the leg, as bump-leg.py: a workflow file or a leg pin that is not UTF-8 is refused by name before anything moves, so a Latin-1 comment no longer ends the run in a UnicodeDecodeError traceback (#289) #352's does for a non-UTF-8 file, since
plan_workflowsreads every file in the directory. - If bump-leg.py: a workflow file or a leg pin that is not UTF-8 is refused by name before anything moves, so a Latin-1 comment no longer ends the run in a UnicodeDecodeError traceback (#289) #352 lands first the change is an
except OSErrorbeside itsUnicodeDecodeErrorin the two helpers, and the same clause around the twoiterdir()calls; if not, the same reads, and the two issues share one helper.
How to test. In tests/test_bump_leg.py, beside test_an_untracked_draft_naming_the_leg_is_refused_by_name_not_committed (:297), with its helpers (reference_both_legs :113, bump :98, branched :93, committed :108) and snapshot and assert_nothing_changed from tests/test_bump_leg_rollback.py (:90, :106) for "nothing moved"; or in #352's tests/test_bump_leg_unreadable.py once it lands, where prepared, commit_bytes and refused_and_untouched (:87, :74, :95) already do the committing and the whole-tree comparison, and MODES runs each case as a bump and as a --dry-run. The test must make git read the tree as clean (git update-index --assume-unchanged <file>, which needs no sleep; or git config core.trustctime false and one git status after the commit), then chmod 000 the file, and restore the mode in a finally so the temporary directory can be removed. Skip on Windows and when os.geteuid() == 0 (root reads anything; tests/test_family_add_refusals.py:678 is the precedent).
- (a) a committed workflow made unreadable: the bump exits 2, stderr holds the refusal id, the file's relative path and
Permission denied, 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.yamlmade unreadable, the same assertions; - (d) control: the same tree with the mode restored bumps, exit 0;
- (e)
.github/workflows/made mode 000: the same assertions, the refusal naming the directory.
Reverting the new handling fails (a) to (c) and (e).
Not in this issue. The default-configuration row: a mode-000 tracked file that git must re-read is M, so bump-leg-root-dirty is the refusal that fires, and its remedy cannot be followed. That refusal is right about the tree (git does see a change); whether its remedy should say more is a separate question.
Related: #289 and PR #352 (the non-UTF-8 half, and the helpers this extends); #253 (closed: the write-side PermissionError, after the leg moves); #359 (the validator's own read of the same files); #285 and #339 (the same gap, an OSError that a tool's except does not name, in update-shape.py apply --add and in scaffold-project.py's pin_digest_from_source).
Lane: openreposhape-2 (openRepoShape-2)
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-2, session 0279452a-ea8e-486f-bf99-698cb0a9bb2f@Eagle, 2026-10-12T15:31:24Z, for #383
Logged in
lanes/log/openRepoShape-2.mdata3c3e26c5b13f3a4165edd3702423c0aaf0e1a30.The three reads (lane-collision-protocol Rule 1):
bump-leg.py: the refusal for a file that could not be opened says no …
bump-leg.py: the leg's pin, the workflows directory and each workflow…
Merge remote-tracking branch 'origin/main' into fix/setup-help-offline
Merge origin/main 75052cb into fix/adopt-root-kept-symlinks: #188's r…
setup.sh: `--help` from anywhere but a checkout is the shim's own few…
Merge main 75052cb into fix/adopt-plan-quote-yaml-keywords (#181)
tests/test_validate_pins_unreadable_workflows_dir.py: the 3.14 stand-…
validate-pins.py and repo_shape.py: a pin, the manifest, a shape copy…
bump-leg.py: a root, a leg's checkout or a `scripts/` this user canno…
family.py: a holder, a member or its object store this user cannot re…