Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/handbook.html
Original file line number Diff line number Diff line change
Expand Up @@ -1312,7 +1312,9 @@ <h3>Advancing a leg</h3>
<p><span class="refuses">Refuses</span> a <code>--to</code> that is not exactly 40 hex; a
commit no branch of the leg&rsquo;s remote contains (a pin the rest of the world cannot fetch
is a root it cannot bootstrap); a dirty root; an untracked or ignored workflow file it would
rewrite; a root standing on its own tracking branch (the next step is a pull request, never a
rewrite; a workflow file or the leg&rsquo;s own pin that is not UTF-8, naming each file and its
first bad byte before anything is written (a file git does not track needs the re-save and no
commit); a root standing on its own tracking branch (the next step is a pull request, never a
push to the default branch); and a validator that goes red &mdash; rolling the pin, the
workflow files, the leg&rsquo;s checkout and the index back first, so a refusal leaves the
tree exactly as it was found.</p>
Expand Down Expand Up @@ -1786,8 +1788,10 @@ <h2>For AI assistants</h2>
<code>family.yaml</code> beside <code>project.yaml</code> as a <strong>manifest kinds</strong>
finding added (#211); <code>bump-leg.py</code> rewriting tracked workflow files only, and
refusing an untracked or ignored one it would rewrite, added (#207); the assistant rules
saying that some scaffold refusals name a value to change, added (#288) &mdash; a page cannot
name the commit that contains it, and the blob below is the fact a test checks.
saying that some scaffold refusals name a value to change, added (#288);
<code>bump-leg.py</code> refusing a workflow file or the leg&rsquo;s pin that is not UTF-8, by
name, added (#289) &mdash; a page cannot name the commit that contains it, and the blob below
is the fact a test checks.
README.md blob e8da93817b0c1a195300800373a2895c0e0280e5. Every command, flag, exit code
and file name on this page is quoted from that README and from AGENTS.md.</p>
</footer>
Expand Down
129 changes: 124 additions & 5 deletions scripts/bump-leg.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
* the root standing on its own tracking branch. The next step this prints
would then be a push to the default branch, and these organisations are
pull-request only.
* a workflow file under `.github/workflows/`, or the leg's
`contracts/<role>-pin.yaml`, that is not UTF-8 (a Latin-1 comment, a
UTF-16 file with a byte-order mark): `bump-leg-workflow-unreadable` and
`bump-leg-pin-unreadable`, naming each file and its first bad byte, before
anything is written and under `--dry-run` as well (#289). Never read
lossily — the rewritten text is written back, and would carry U+FFFD
where the operator's byte was — and never skipped, since `validate-pins.py`
reads every workflow file on disk and cannot read it either. A workflow
file git does not track is refused the same way and marked `(untracked or
ignored)`: it needs the re-save and no commit, where a tracked one is
committed after it.
* a validator that goes red after the rewrite. Every byte is rolled back
first — the pin file, the workflow files, the leg's checkout and the index
— so a refusal leaves the tree exactly as it was found.
Expand Down Expand Up @@ -297,6 +308,115 @@ def _tracked_workflows(root: Path, workflows: Path) -> set[str]:
return {os.fsdecode(name) for name in raw.split(b"\0") if name}


def _not_utf8(root: Path, path: Path, exc: UnicodeDecodeError) -> str:
"""`<path relative to root>: not UTF-8 at byte N (<reason>)`, in the
words `repo_shape.load_yaml` gives the same failure (#192), so a file this
tool cannot read is named the way every other reader names one."""
return (f"{path.relative_to(root).as_posix()}: not UTF-8 at byte "
f"{exc.start} ({exc.reason})")


def _leg_pin_text(root: Path, pin_path: Path) -> str:
"""The leg's `contracts/<role>-pin.yaml` as text, or a refusal naming it
when it is not UTF-8 (#289).

Read here rather than through `load_yaml` because `rewrite_pin` is a LINE
REWRITE of the file's own text, which a parsed mapping cannot be. A
`UnicodeDecodeError` is a `ValueError`: nothing on the path catches it and
`main()` names `Refusal` and `CommandFailed` alone, so it ended the run in
a traceback, exit 1, under `--dry-run` as well, because this read comes
before that early return. It comes before anything is written, so the
refusal promises only what is true: the working tree, the index and the
leg's checkout are as they were. (The leg's object store was fetched into
already, which `--dry-run` says it does.)
"""
try:
return pin_path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise Refusal(
"bump-leg-pin-unreadable",
_not_utf8(root, pin_path, exc),
"Remediation: this tool rewrites `commit:` and "
"`digests.tree_sha256` in this file as UTF-8 text, and reads no "
"file lossily, because the rewritten text is written back and "
"would carry U+FFFD where your byte was. Byte "
f"{exc.start}, counting from 0 at the start of the file, is the "
"first that is not UTF-8: re-save the file as UTF-8, or replace "
"that byte with the character it was meant to be, commit it, and "
"run the same command again. Nothing in the working tree, the "
"index or the leg's checkout was changed.") from exc


def _workflow_texts(root: Path, workflows: Path, tracked: set[str],
listed: set[str]) -> list[tuple[Path, str]]:
"""Every `.yml` and `.yaml` file in `workflows`, read as UTF-8 — or ONE
refusal naming each that is not (#289).

EVERY FILE IS READ, whether or not it names the leg, so a file that is not
UTF-8 is REFUSED BY NAME and never skipped, never read lossily.
`read_text(encoding="utf-8")` on a Latin-1 comment or a UTF-16 file with a
byte-order mark raised `UnicodeDecodeError`, which nothing caught: a
traceback, exit 1, under `--dry-run` too, because this read comes before
that early return. Skipping the file would leave `validate-pins.py`, which
reads every workflow on disk, unable to read it either, and
`errors="replace"` would write U+FFFD over the operator's byte into the
lockstep commit. All of them are named at once, so the operator re-saves
each in one pass instead of meeting them one run at a time. The read is
before anything is written, so the refusal promises only what is true:
the working tree, the index and the leg's checkout are as they were.

EACH LINE SAYS WHETHER GIT TRACKS THE FILE, because the advice differs.
A tracked file is committed after the re-save. One git does not track,
untracked or ignored, needs only the re-save: committing it would put a
draft on the bump branch, which `plan_workflows` refuses an untracked
file for (#207), and `git add` refuses an ignored one. `tracked` and
`listed` are `plan_workflows`' own, and `_tracked_spelling` answers with
them, so a tracked file the disk spells another way (#332) is not marked.
It is asked only of a file already refused and reads no file (`tracked` is
in hand, `_same_file` is a `stat`), so every read still comes before
anything is written.
"""
texts: list[tuple[Path, str]] = []
unreadable: list[tuple[str, bool]] = []
for path in sorted(workflows.iterdir()):
if path.suffix not in (".yml", ".yaml") or not path.is_file():
continue
try:
texts.append((path, path.read_text(encoding="utf-8")))
except UnicodeDecodeError as exc:
held = _tracked_spelling(root, path, tracked, listed) is not None
unreadable.append((_not_utf8(root, path, exc), held))
if unreadable:
advice = ""
if any(held for _, held in unreadable):
advice += "Commit each file git tracks. "
if not all(held for _, held in unreadable):
advice += ("A file marked (untracked or ignored) needs no "
"commit, only the re-save: committing it would put "
"it on this branch, so either leave it uncommitted "
"or move it out of .github/workflows/ until the bump "
"has landed. ")
raise Refusal(
"bump-leg-workflow-unreadable",
f"{len(unreadable)} workflow file(s) are not UTF-8:\n"
+ "\n".join(f" {line}" + ("" if held
else " (untracked or ignored)")
for line, held in unreadable),
"Remediation: this tool rewrites each workflow file as UTF-8 "
"text and reads none lossily, because the rewritten text is "
"written back and would carry U+FFFD where your byte was; "
"`scripts/validate-pins.py` reads every workflow file on disk as "
"UTF-8 too, so skipping one would only move the failure there. "
"In each line above, the byte is counted from 0 at the start of "
"the file and is the first that is not UTF-8: re-save every file "
"named as UTF-8 (a UTF-16 file with a byte-order mark needs "
"converting, not editing), whether or not it names this leg. "
+ advice
+ "Then run the same command again. Nothing in the working "
"tree, the index or the leg's checkout was changed.")
return texts


def _same_file(first: Path, second: Path) -> bool:
"""Whether the filesystem resolves both names to ONE file — False when
either cannot be resolved at all.
Expand Down Expand Up @@ -388,6 +508,8 @@ def plan_workflows(root: Path, repository: str,
names the path git tracks and no other, and the rewrite reaches the one
file through it, read through the disk's name and written through
git's, which the filesystem has already said are the same file.

A file that is not UTF-8 is a refusal (`_workflow_texts`, #289).
"""
plans: list[tuple[Path, str, int]] = []
workflows = root / ".github" / "workflows"
Expand All @@ -396,9 +518,7 @@ def plan_workflows(root: Path, repository: str,
tracked = _tracked_workflows(root, workflows)
listed = {child.name for child in workflows.iterdir()}
untracked: list[tuple[str, int]] = []
for path in sorted(workflows.iterdir()):
if path.suffix not in (".yml", ".yaml") or not path.is_file():
continue
for path, text in _workflow_texts(root, workflows, tracked, listed):
moved = 0

def swap(match: re.Match) -> str:
Expand All @@ -411,7 +531,6 @@ def swap(match: re.Match) -> str:
return (f"{match.group('repo')}{match.group('path') or ''}"
f"@{commit}")

text = path.read_text(encoding="utf-8")
rewritten = WORKFLOW_REF_RE.sub(swap, text)
if not moved:
continue
Expand Down Expand Up @@ -1114,7 +1233,7 @@ def cmd_bump(args) -> int:
digest = tree_digest(submodule, commit)
pin_rel = f"contracts/{role}-pin.yaml"
pin_path = _leg_pin_path(root, pin_rel)
pin_text = pin_path.read_text(encoding="utf-8")
pin_text = _leg_pin_text(root, pin_path)
plans = plan_workflows(root, repository, commit)

print(f"commit {was[:12]} -> {commit[:12]}")
Expand Down
Loading
Loading