Skip to content

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

Closed

Description

@brettheap

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

  1. Refuse by name, before anything moves, exit 2, under --dry-run as 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 pin bump-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.
  2. Cover the OSError family, not PermissionError alone: a workflow file that is a directory-turned-file, or vanishes between iterdir() 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.
  3. 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_workflows reads every file in the directory.
  4. 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 OSError beside its UnicodeDecodeError in the two helpers, and the same clause around the two iterdir() 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, 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 made 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

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-2, session 0279452a-ea8e-486f-bf99-698cb0a9bb2f@Eagle, 2026-10-12T15:31:24Z, for #383

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

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

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#383:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#383"`, kept where the row or its body names `#383`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `383` as a whole token:
       none
    
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