diff --git a/docs/cli.md b/docs/cli.md index 64e50575..1c02bb97 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,7 +44,7 @@ one today, and the generator refuses to write this file if one ever appears. | tool | what it is for | | --- | --- | -| [`templates/assembly-root/scripts/bootstrap.py`](#templatesassembly-rootscriptsbootstrappy) | The one command after `git clone --recurse-submodules`: places each leg on its tracking branch AT the pinned commit. What `make bootstrap` runs. | +| [`templates/assembly-root/scripts/bootstrap.py`](#templatesassembly-rootscriptsbootstrappy) | The one command after `git clone --recurse-submodules`: places each leg on its tracking branch AT the pinned commit only where that loses no commit, and otherwise leaves its checkout where it is, saying why. What `make bootstrap` runs. | | [`templates/assembly-root/scripts/validate-manifest.py`](#templatesassembly-rootscriptsvalidate-manifestpy) | The project's own `project.yaml` validator: the manifest against the naming and path policies it was cut from. | | [`templates/assembly-root/scripts/validate-pins.py`](#templatesassembly-rootscriptsvalidate-pinspy) | The project's own LOCKSTEP validator: the gitlink, the pin and the workflow references must agree, and every digest must recompute. | | [`templates/family-root/scripts/bootstrap.py`](#templatesfamily-rootscriptsbootstrappy) | The holder's own bootstrap: mounts every member at its pin and runs each member's. | diff --git a/scripts/render-cli-reference.py b/scripts/render-cli-reference.py index 92d6a835..aaf08984 100755 --- a/scripts/render-cli-reference.py +++ b/scripts/render-cli-reference.py @@ -180,8 +180,9 @@ class Tool(NamedTuple): Tool("templates/assembly-root/scripts/bootstrap.py", "python", "python3 scripts/bootstrap.py", "The one command after `git clone --recurse-submodules`: places each " - "leg on its tracking branch AT the pinned commit. What `make " - "bootstrap` runs.", + "leg on its tracking branch AT the pinned commit only where that " + "loses no commit, and otherwise leaves its checkout where it is, " + "saying why. What `make bootstrap` runs.", "scripts/bootstrap.py", INSTALLED_IN_ASSEMBLY_ROOT), Tool("templates/assembly-root/scripts/validate-manifest.py", "python", "python3 scripts/validate-manifest.py", diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index cec14c25..f7531de7 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -67,9 +67,16 @@ wallet-carried authority is not misconfigured, and a bootstrap that failed there would have made the layout load-bearing again. - (d) EXITS 0 unless a validator failed, and 2 where step (a) or the - upstream notice cannot start git, refused `git-not-runnable` as the - validators refuse it (#694). + (d) EXITS 0 unless a validator failed, and says which way. A validator + that FOUND something, any exit but 2, ends the run `bootstrap FAILED: + (exit )`, exit 1. A validator that REFUSED, its exit 2, + could not check at all, and the run ends `bootstrap REFUSED: + (exit 2)`, exit 2 (#764). Where one validator found and another + refused, both lines are said, the finding first, and the run exits 2, + since a check that could not run outranks a finding. Step (a) and the + upstream notice refuse `git-not-runnable`, exit 2, where git cannot be + started, as the validators do (#694): the run stops there and says + neither line, though a validator may have found or refused by then. SCHEMA-NEUTRAL BY CONTRACT. A one-repository project runs the same command: step (a) is a no-op where there are no submodules, and steps (c) and (d) are @@ -129,6 +136,13 @@ ("CHERRY_PICK_HEAD", "a cherry-pick", "cherry-pick"), ("REVERT_HEAD", "a revert", "revert"), ) +#: The two `IN_PROGRESS` markers git writes as REFS (#764). On a leg whose +#: refs are in a reftable (`git init --ref-format=reftable`, git 2.45 and +#: later) neither is a file in the git dir while its operation is in +#: progress: each is in the ref store, which step (a) asks as well +#: (`_in_ref_store`). `MERGE_HEAD` is a file on both backends, as the two +#: rebases' directories and `sequencer/todo` are (measured with git 2.47.1). +IN_REF_STORE = ("CHERRY_PICK_HEAD", "REVERT_HEAD") #: What a cherry-pick or a revert of SEVERAL commits leaves while it is not #: done, asked after `IN_PROGRESS`: its stop committed by hand takes the #: head file away and leaves this, whose first word says which it is. @@ -149,6 +163,9 @@ ("manifest", ["scripts/validate-manifest.py"]), ("pins", ["scripts/validate-pins.py"]), ) +#: The exit code a validator ends a REFUSAL with: it could not check, which +#: is not a finding (#764). Every other non-zero code is a failure. +VALIDATOR_REFUSED = 2 def _run(root: Path, args: list[str]) -> int: @@ -672,9 +689,10 @@ def _on_a_branch(current: str | None) -> bool: return current is not None and current.startswith("refs/heads/") -def _in_progress(sub: Path) -> tuple[str, str, str] | None: +def _in_progress(sub: Path) -> tuple[tuple[str, str, str], str] | None: """The `IN_PROGRESS` row of the operation git has in progress in the - leg, or `None` (#704). + leg, with where its marker is (`git dir` or `ref store`), or `None` + (#704). Asked of the leg's own git dir, which `git rev-parse --git-dir` names (a submodule's is under the root's `.git/modules/`, a linked @@ -683,6 +701,12 @@ def _in_progress(sub: Path) -> tuple[str, str, str] | None: path, so no name of a directory can fail the read. A leg git names no git dir for has nothing to read here, and the move after this says what git says of it. + + A cherry-pick's or a revert's marker is asked of the leg's ref store + too (`IN_REF_STORE`, #764). On a reftable leg it is there and in no + file, so a leg stopped in either was moved, and the move ended it as + #704 measured on the files backend: `CHERRY_PICK_HEAD` or `REVERT_HEAD` + gone, and what it had staged carried onto the tracking branch. """ try: said = git_out(["rev-parse", "--git-dir"], cwd=sub, binary=True) @@ -691,8 +715,24 @@ def _in_progress(sub: Path) -> tuple[str, str, str] | None: gitdir = sub / os.fsdecode(said.rstrip(b"\r\n")) for row in IN_PROGRESS: if os.path.lexists(gitdir / row[0]): - return row - return _sequence_in_progress(gitdir) + return row, "git dir" + if row[0] in IN_REF_STORE and _in_ref_store(sub, row[0]): + return row, "ref store" + sequence = _sequence_in_progress(gitdir) + return None if sequence is None else (sequence, "git dir") + + +def _in_ref_store(sub: Path, name: str) -> bool: + """Whether the leg's ref store holds the ref called exactly `name` + (#764). + + `git show-ref --exists` (git 2.43 and later) takes the name as it is, + where `git rev-parse --verify CHERRY_PICK_HEAD` also resolves a branch + or a tag of that name, which no operation leaves. A git before 2.43 + refuses the option and is answered no: such a git has no reftable + either, so the file `_in_progress` asks first is the whole answer + there.""" + return _try_git(["show-ref", "--exists", name], sub) is not None def _sequence_in_progress(gitdir: Path) -> tuple[str, str, str] | None: @@ -718,11 +758,12 @@ def _sequence_in_progress(gitdir: Path) -> tuple[str, str, str] | None: return SEQUENCER_TODO, "a cherry-pick", "cherry-pick" -def _say_in_progress(label: str, path: str, - row: tuple[str, str, str]) -> None: +def _say_in_progress(label: str, path: str, row: tuple[str, str, str], + held: str) -> None: """A leg with an operation in progress (#704): what is in progress, - that nothing moved, and the two commands that end it, the leg's path - shell-quoted, then spelled, as `move_back_to_pin` prints its command. + where git holds its marker (`held`, #764), that nothing moved, and the + two commands that end it, the leg's path shell-quoted, then spelled, as + `move_back_to_pin` prints its command. No move is made from here. A checkout of the tracking branch, even from HEAD on it already or detached at the pin, ends a merge, a cherry-pick @@ -736,7 +777,7 @@ def _say_in_progress(label: str, path: str, marker, doing, verb = row command = f"git -C {spelled(shlex.quote(path))} {verb}" print(f" {label}: {doing} is in progress in the leg ({marker} is in its " - "git dir), so nothing moved") + f"{held}), so nothing moved") print(f" finish it (`{command} --continue`) or abort it " f"(`{command} --abort`), then run bootstrap again.") @@ -1030,7 +1071,7 @@ def checkout_tracking_branch(root: Path, leg: dict, branch: str) -> None: f"refs/remotes/origin/{branch}"], sub) doing = _in_progress(sub) if doing is not None: - _say_in_progress(where, path, doing) + _say_in_progress(where, path, *doing) return if local is None: @@ -1071,30 +1112,24 @@ def shape_upstream_notice(root: Path) -> None: an absent answer is not a finding, and it never fails the bootstrap. A clone named there that git cannot be STARTED to read is no absent answer but a machine with no git, refused `git-not-runnable` (#694). + + A READ THE SYSTEM REFUSES IS AN ABSENT ANSWER TOO (#764), and so is a + `contracts/shape-pin.yaml` that is not a mapping, which `validate-pins.py` + has refused by then: each ended `make bootstrap` in a traceback, exit 1. + The two reads of the answer itself are `_clone_tip` and `_cached_tip`. """ pin_path = root / "contracts" / "shape-pin.yaml" - if not pin_path.is_file(): - return try: - pin = load_yaml(pin_path) - except Refusal: + pin = load_yaml(pin_path) if pin_path.is_file() else None + except (OSError, Refusal): return - pinned = str((pin or {}).get("commit") or "").lower() + pinned = str((pin if isinstance(pin, dict) else {}).get("commit") + or "").lower() if not COMMIT_40.match(pinned): return - tip = None - upstream = os.environ.get(SHAPE_UPSTREAM_ENV) - if upstream and Path(upstream).is_dir(): - with _git_runs(f"the clone {SHAPE_UPSTREAM_ENV} names, " - f"{spelled(upstream)}, could not be read"): - tip = _try_git(["rev-parse", "HEAD"], Path(upstream)) + tip = _clone_tip(os.environ.get(SHAPE_UPSTREAM_ENV)) if tip is None: - cache = root / SHAPE_UPSTREAM_CACHE - if cache.is_file(): - # `utf-8-sig` takes off the byte-order mark a Windows editor may - # start the file with (#855). `strip()` keeps U+FEFF, so a marked - # tip read as no commit and the notice said nothing. - tip = cache.read_text(encoding="utf-8-sig").strip() + tip = _cached_tip(root) if not tip or not COMMIT_40.match(tip): return if tip.lower() == pinned: @@ -1105,6 +1140,48 @@ def shape_upstream_notice(root: Path) -> None: f"is wrong until you have.") +def _clone_tip(upstream: str | None) -> str | None: + """`HEAD` of the clone `SHAPE_UPSTREAM_PATH` names, or `None` where no + clone there can be read (#764). + + A directory this process may not enter is no clone it can read. Python + 3.12 and 3.13 raise `PermissionError` from `Path.is_dir` for a path + under one, and `subprocess` raises it for a `cwd` it cannot enter, + naming that directory, which `_git_runs` passes on as not git's. Each + ended `make bootstrap` in a traceback, exit 1, where the notice is + silent over every other absent answer, and the cache is still asked. A + git that cannot be STARTED is still `git-not-runnable` (#694): that is + the `Refusal` `_git_runs` raises, which `except OSError` does not catch. + """ + if not upstream: + return None + try: + if not Path(upstream).is_dir(): + return None + with _git_runs(f"the clone {SHAPE_UPSTREAM_ENV} names, " + f"{spelled(upstream)}, could not be read"): + return _try_git(["rev-parse", "HEAD"], Path(upstream)) + except OSError: + return None + + +def _cached_tip(root: Path) -> str | None: + """What `.shape-upstream-tip` holds, less a byte-order mark it starts + with (#855), or `None` where it cannot be read: not there, a file this + process may not read, or bytes that are not UTF-8, none of which is a + commit (#764). The last two ended `make bootstrap` in a `PermissionError` + or a `UnicodeDecodeError` traceback.""" + cache = root / SHAPE_UPSTREAM_CACHE + try: + # `utf-8-sig` takes off the byte-order mark a Windows editor may + # start the file with (#855). `strip()` keeps U+FEFF, so a marked + # tip read as no commit and the notice said nothing. + return (cache.read_text(encoding="utf-8-sig").strip() + if cache.is_file() else None) + except (OSError, UnicodeDecodeError): + return None + + def _register_paths(root: Path, legs: list[dict]) -> list[Path]: candidates = [root / REGISTER_RELPATH] for leg in legs: @@ -1312,11 +1389,12 @@ def _checkout_all_tracking_branches(root: Path, sub_legs: list[dict], def _run_neutral_validators(root: Path, manifest: dict | None, - skip: bool) -> list[str]: + skip: bool) -> list[tuple[str, int]]: """Step (b): run the neutral validators, then the upstream notice. - Split from `main` for #132. Returns the labels of any validator that - failed, exactly as `main`'s local `failed` list used to accumulate them. + Split from `main` for #132. Returns the label and the exit code of each + validator that failed, in the order they ran: `_end_of_run` says which + were findings and which refusals (#764). """ if skip: print(" skipped (--skip-validators)") @@ -1334,11 +1412,41 @@ def _run_neutral_validators(root: Path, manifest: dict | None, print(f" --- {label} ---") code = _run(root, argv_) if code != 0: - failed.append(f"{label} (exit {code})") + failed.append((label, code)) shape_upstream_notice(root) return failed +def _end_of_run(failed: list[tuple[str, int]]) -> int: + """The last line or two of the run, and its exit code (#764). + + A validator's exit 2 is its REFUSAL, that it could not check, and is said + as one: `bootstrap REFUSED: (exit 2)`, exit 2, the code every + other refusal here ends with. Any other non-zero exit is a finding, as it + was: `bootstrap FAILED: (exit )`, exit 1. Where both + happened both lines are said, the findings first, and the run exits 2, + since a check that could not run outranks a finding. + """ + # Stdout first: piped, it is block-buffered and stderr is not, so a log of + # both would show the lines below before step (c)'s, as `main`'s + # `git-not-runnable` path takes care that it does not. + sys.stdout.flush() + found = [f"{label} (exit {code})" for label, code in failed + if code != VALIDATOR_REFUSED] + refused = [f"{label} (exit {code})" for label, code in failed + if code == VALIDATOR_REFUSED] + if found: + print("bootstrap FAILED: " + ", ".join(found), file=sys.stderr) + if refused: + print("bootstrap REFUSED: " + ", ".join(refused), file=sys.stderr) + if refused: + return 2 + if found: + return 1 + print("bootstrap ok") + return 0 + + def main(argv: list[str] | None = None) -> int: escape_unencodable_stdout() parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) @@ -1380,11 +1488,7 @@ def main(argv: list[str] | None = None) -> int: read_authority(root, legs) print() - if failed: - print("bootstrap FAILED: " + ", ".join(failed), file=sys.stderr) - return 1 - print("bootstrap ok") - return 0 + return _end_of_run(failed) if __name__ == "__main__": diff --git a/tests/test_bootstrap_placement_texts.py b/tests/test_bootstrap_placement_texts.py new file mode 100644 index 00000000..b11b71e4 --- /dev/null +++ b/tests/test_bootstrap_placement_texts.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`docs/cli.md` says what the assembly root's bootstrap places with the +condition #704 holds it to (#764, C3). + +THE DEFECT. The flag-level reference's row for +`templates/assembly-root/scripts/bootstrap.py` said it "places each leg on +its tracking branch AT the pinned commit", with no condition. Since #704 +(Brett Heap's R1 and R2, 2026-10-16T13:07:34Z) it places a leg there only +where that loses no commit, and `AGENTS-shape.md`, which every project +carries, says so in R2's words. The row is generated from +`scripts/render-cli-reference.py`, so `tests/test_cli_reference.py` held the +two in step and neither to the rule. + +THE RULE NOW. The row carries R2's condition, word for word as the pinned +`AGENTS-shape.md` says it. The other pinned sentences, which say bootstrap +places a leg there "or says why not" (the Makefile's help, the family +holder's `AGENTS-shape.md` and README, and `AGENTS-shape.md`'s own table of +five commands), are true as they stand, so R2 left them and so does this. +""" + +from __future__ import annotations + +import re + +from conftest import REPO + +#: R2's condition, as the pinned `AGENTS-shape.md` words it. +CONDITION = ("only where that loses no commit, and otherwise leaves its " + "checkout where it is, saying why") + + +def one_line(path) -> str: + """The file's text with every run of white space made one space, so a + sentence wrapped across lines reads as one.""" + return re.sub(r"\s+", " ", path.read_text(encoding="utf-8")) + + +def test_the_pinned_agent_file_still_says_r2s_condition(): + """The words this file holds the reference to are R2's own.""" + agents = one_line(REPO / "templates" / "assembly-root" / + "AGENTS-shape.md") + assert ("`make bootstrap` places a leg on its tracking branch AT the " + f"pin {CONDITION}.") in agents + + +def test_the_reference_row_for_the_assembly_bootstrap_says_the_condition(): + row = next(line for line in (REPO / "docs" / "cli.md").read_text( + encoding="utf-8").splitlines() + if line.startswith("| [`templates/assembly-root/scripts/" + "bootstrap.py`]")) + assert (f"places each leg on its tracking branch AT the pinned commit " + f"{CONDITION}. What `make bootstrap` runs. |") in row, row diff --git a/tests/test_bootstrap_ref_store.py b/tests/test_bootstrap_ref_store.py new file mode 100644 index 00000000..e8a9eb2b --- /dev/null +++ b/tests/test_bootstrap_ref_store.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A cherry-pick or a revert in progress on a REFTABLE leg is refused as one +on the files backend is (#764, C4). + +THE PROBE, with git 2.47.1 built for it (this host's git, 2.43.0, has no +reftable): in a repository made by `git init --ref-format=reftable`, a +cherry-pick stopped on a conflict and a `git revert` stopped on one leave NO +`CHERRY_PICK_HEAD` or `REVERT_HEAD` file in `.git`. `git show-ref --exists` +finds each in the ref store, and `git status` says the operation is in +progress. A stopped merge leaves `MERGE_HEAD` a FILE there, as on the files +backend. + +THE DEFECT, reproduced with that git on lane 5's #745 head (f4935e11): a +scaffold cloned with `GIT_DEFAULT_REF_FORMAT=reftable`, so the root and both +legs are reftable, bootstrapped once, then a cherry-pick (or a revert) +stopped on `other` in the spec leg. `_in_progress` asked for the marker +FILES only and found none, so bootstrap checked `main` out and printed `on +main at ` and `the leg was on other`. The cherry-pick was ENDED, its +staged README carried onto `main`; the revert was ended too, its staged +removal gone. That is #704's line 2, which Brett Heap's R1 refuses, met +again on the other ref backend. A merge was refused, by its file. + +THE RULE NOW. `CHERRY_PICK_HEAD` and `REVERT_HEAD` are asked of the leg's +ref store too, by the exact name (`git show-ref --exists`, git 2.43 and +later), so a branch or a tag of that name is no operation. The refusal says +where git holds the marker: `is in its ref store`, or `is in its git dir` +as before. + +The reftable cases skip where git makes no reftable repository (before +2.45); CI's runner image has a git that does. The exact-name case runs on +every git. +""" + +from __future__ import annotations + +import shlex +import shutil +from pathlib import Path + +import pytest + +from conftest import PROJECT, cloned_just_pushed, git +from test_bootstrap_after_bump import MAIN, head_ref, rev, skip_validators +from test_bootstrap_leg_moves import ( + OPERATIONS, placed_once_cleared, snapshot, +) + +#: Where git holds each operation's marker on a reftable leg (the probe). +HELD = {"cherry-pick": "ref store", "revert": "ref store", "merge": "git dir"} + + +def in_progress(operation: str, held: str) -> str: + """The two lines step (a) prints for a spec leg with `operation` in + progress, its marker held in `held`.""" + _, marker, doing, verb = OPERATIONS[operation] + return (f" [spec] spec: {doing} is in progress in the leg ({marker} is " + f"in its {held}), so nothing moved\n" + f" finish it (`git -C spec {verb} --continue`) or abort " + f"it (`git -C spec {verb} --abort`), then run bootstrap again.\n") + + +def held_in(leg: Path, marker: str) -> str | None: + """Where the leg's git holds `marker`: `git dir` for a file there, + `ref store` for a ref by that exact name and no file, else None.""" + gitdir = Path(git("rev-parse", "--absolute-git-dir", cwd=leg).stdout + .strip()) + if (gitdir / marker).exists(): + return "git dir" + held = git("show-ref", "--exists", marker, cwd=leg, check=False) + return "ref store" if held.returncode == 0 else None + + +@pytest.fixture(scope="module") +def reftable_clone(scaffolded, tmp_path_factory) -> Path: + """The session's scaffold cloned recursively by a git that makes every + new repository reftable (`GIT_DEFAULT_REF_FORMAT`, git 2.45), and + bootstrapped once, so each leg is on `main` at its pin.""" + clone = cloned_just_pushed( + scaffolded["remotes"] / f"{PROJECT}.git", + tmp_path_factory.mktemp("reftable") / PROJECT, + env={"GIT_DEFAULT_REF_FORMAT": "reftable"}) + format_ = git("rev-parse", "--show-ref-format", cwd=clone / "spec", + check=False).stdout.strip() + if format_ != "reftable": + pytest.skip("this git makes no reftable repository (git 2.45 and " + "later do)") + first = skip_validators(clone) + assert first.returncode == 0, first.stderr + first.stdout + assert head_ref(clone / "spec") == MAIN, first.stdout + return clone + + +@pytest.fixture +def reftable_root(reftable_clone, tmp_path) -> Path: + """A private copy of `reftable_clone`; the reftable is plain files, and + a leg's `.git` file names `.git/modules/` relatively.""" + target = tmp_path / PROJECT + shutil.copytree(reftable_clone, target, symlinks=True) + return target + + +@pytest.mark.parametrize("operation", sorted(HELD)) +def test_an_operation_in_progress_on_a_reftable_leg_is_refused( + reftable_root, operation): + """A cherry-pick, a revert and a merge in progress on `other`, started + from the pin, in a reftable leg: each is refused before any move, the + line saying where git holds its marker, and nothing moves. The `--abort` + it prints, typed from the root, ends it, and the next run places the + leg.""" + leg = reftable_root / "spec" + pin = rev(leg) + make, marker = OPERATIONS[operation][:2] + make(leg, pin) + assert held_in(leg, marker) == HELD[operation], f"{operation} is held" + before = snapshot(leg) + + result = skip_validators(reftable_root) + assert result.returncode == 0, result.stderr + result.stdout + expected = in_progress(operation, HELD[operation]) + assert expected in result.stdout, result.stdout + assert snapshot(leg) == before, "nothing moved" + assert held_in(leg, marker) == HELD[operation], f"the {operation} goes on" + + abort = expected.splitlines()[1].split("`")[3] + assert abort == f"git -C spec {OPERATIONS[operation][3]} --abort", abort + git(*shlex.split(abort)[1:], cwd=reftable_root) + placed_once_cleared(reftable_root, pin) + + +def test_a_branch_or_a_tag_named_as_a_marker_is_no_operation(project): + """A recursive clone's leg, detached at the pin, with a branch called + `CHERRY_PICK_HEAD` and a tag called `REVERT_HEAD` and no operation: + `git rev-parse --verify` resolves both names, and `git show-ref + --exists` neither, so the leg is placed as it is without them. Runs on + every git, the files backend included.""" + leg = project / "spec" + pin = rev(leg) + assert head_ref(leg) is None, "a recursive clone leaves the leg detached" + git("branch", "CHERRY_PICK_HEAD", pin, cwd=leg) + git("tag", "REVERT_HEAD", pin, cwd=leg) + assert git("rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD", + cwd=leg).stdout.strip() == pin, "the branch resolves" + + result = skip_validators(project) + assert result.returncode == 0, result.stderr + result.stdout + assert f" [spec] spec: on main at {pin[:12]} (branch tip == pin)\n" in ( + result.stdout), result.stdout + assert "is in progress" not in result.stdout, result.stdout + assert head_ref(leg) == MAIN diff --git a/tests/test_bootstrap_refusal_exit.py b/tests/test_bootstrap_refusal_exit.py new file mode 100644 index 00000000..ef2931d6 --- /dev/null +++ b/tests/test_bootstrap_refusal_exit.py @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A validator that REFUSES ends the assembly root's `bootstrap.py` as a +refusal, exit 2; one that FINDS still ends it `bootstrap FAILED`, exit 1 +(#764, C2, option B, Brett Heap's ruling of 2026-10-17T13:01:10Z: "take B for +#764's C2"). + +THE DEFECT, measured on lane 5's #815 head (271710ba) in a PLAIN clone, which +is any person who forgot `--recurse-submodules`, and not only a machine with +no git: `validate-pins.py` refuses `pin-submodule-uninitialized`, exit 2, and +bootstrap said `bootstrap FAILED: pins (exit 2)` and exited 1, as it does for +a finding. Exit 1 is a finding's code and exit 2 is the code every other +refusal here ends with (`git-not-runnable`, a root that cannot be found), so +a script or a setup run that read the code could not tell "this project is +wrong" from "nothing could be checked". + +THE RULE NOW. +- A validator's exit 2 prints `bootstrap REFUSED: (exit 2)`, and + bootstrap exits 2. +- Any other non-zero exit, a finding, prints `bootstrap FAILED: (exit + )` and bootstrap exits 1, as it did. +- A run where one validator finds and another refuses prints both lines, the + findings first, and exits 2: a check that could not run outranks a finding. + +`make bootstrap` exits 2 for any failing recipe, as the Makefile's own comment +says, so only its `Error ` line changes, from `Error 1` to `Error 2`. +What changes for a person is the exit of `bootstrap.py` run directly, as +`setup-project.py` step (7) and `siblings.py` run it. + +A PLAIN CLONE WITH NO GIT is #764's own Verify case (rv704's): no git on `PATH` +and nothing initialized, so step (a) runs no git, and `validate-pins.py` +refuses `git-not-runnable`. It ends `bootstrap REFUSED: pins (exit 2)` too. +Where `SHAPE_UPSTREAM_PATH` names a directory the offline notice would run +git in, it refuses `git-not-runnable` first (#694) and the run says neither +line: the module docstring's step (d) says so, and a test holds it. + +BOTH STREAMS IN ONE PIPE read in the order the run went: stdout is +block-buffered when piped and stderr is not, so without a flush the ending's +line came before step (c)'s. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import (PROJECT, REPO, clone_just_pushed, load_without_bytecode, + run_script) +from test_bootstrap_without_git import without_git + +FAILED = "bootstrap FAILED: manifest (exit 1)" +REFUSED = "bootstrap REFUSED: pins (exit 2)" +#: What the pins validator says first, in a plain clone. +PINS_CODE = "pin-submodule-uninitialized" + + +def bootstrap(root: Path): + """`scripts/bootstrap.py` in `root`, the validators included, with no + upstream clone named.""" + return run_script(root / "scripts" / "bootstrap.py", cwd=root, + env={"SHAPE_UPSTREAM_PATH": ""}) + + +def a_finding(root: Path) -> None: + """A manifest the manifest validator FINDS wrong, exit 1, and which + steps (a) and the pins validator read no differently.""" + manifest = root / "project.yaml" + text = manifest.read_text(encoding="utf-8") + found = re.sub(r'(?m)^visibility: ".*"$', 'visibility: "hidden"', text) + assert found != text, text + manifest.write_bytes(found.encode("utf-8")) + + +@pytest.fixture +def plain(scaffolded, tmp_path) -> Path: + """A PLAIN clone of the scaffold's root: no `--recurse-submodules`, so + both legs' mounts are empty and `validate-pins.py` refuses.""" + clone = tmp_path / PROJECT + made = clone_just_pushed(scaffolded["remotes"] / f"{PROJECT}.git", clone, + recurse=False) + assert made.returncode == 0, made.stderr + return clone + + +def ends(result, code: int, *lines: str) -> None: + """The run's exit, no traceback, and stderr ending on `lines`.""" + assert result.returncode == code, result.stderr + result.stdout + assert "Traceback" not in result.stderr, result.stderr + assert result.stderr.splitlines()[-len(lines):] == list(lines), ( + result.stderr) + assert "bootstrap ok" not in result.stdout, result.stdout + + +def test_a_refusing_validator_ends_the_run_refused_with_exit_2(plain): + """On #815's head: `bootstrap FAILED: pins (exit 2)`, exit 1.""" + result = bootstrap(plain) + ends(result, 2, REFUSED) + assert PINS_CODE in result.stderr, result.stderr + assert "bootstrap FAILED" not in result.stderr, result.stderr + # The run still goes on to step (c): only its end changed. + assert "(c) review authority" in result.stdout, result.stdout + + +def test_findings_alone_still_end_the_run_failed_with_exit_1(project): + """The same line and the same code as before, in a recursive clone whose + pins are in order.""" + a_finding(project) + result = bootstrap(project) + ends(result, 1, FAILED) + assert "manifest-visibility" in result.stderr, result.stderr + assert "bootstrap REFUSED" not in result.stderr, result.stderr + + +def test_a_finding_and_a_refusal_say_both_and_exit_2(plain): + """The manifest validator finds and the pins validator refuses: both + lines, the finding first, and exit 2.""" + a_finding(plain) + result = bootstrap(plain) + ends(result, 2, FAILED, REFUSED) + assert "manifest-visibility" in result.stderr, result.stderr + assert PINS_CODE in result.stderr, result.stderr + + +@pytest.mark.skipif(shutil.which("make") is None or + shutil.which("python3") is None, + reason="the Makefile runs `python3` under `make`") +def test_make_bootstrap_says_refused_and_make_names_error_2(plain): + """GNU make exits 2 for any failing recipe, so its exit is what it was; + its own line now carries the recipe's code, 2.""" + proc = subprocess.run(["make", "bootstrap"], cwd=str(plain), + env={**os.environ, "SHAPE_UPSTREAM_PATH": ""}, + capture_output=True, stdin=subprocess.DEVNULL, + encoding="utf-8", errors="replace", check=False) + assert proc.returncode == 2, proc.stderr + proc.stdout + assert REFUSED in proc.stderr, proc.stderr + assert re.search(r"\] Error 2\b", proc.stderr), proc.stderr + assert "Error 1" not in proc.stderr, proc.stderr + + +def test_a_plain_clone_with_no_git_ends_refused_with_exit_2(plain, tmp_path): + """#764's Verify case for C2: "a plain clone with no git, asserting the + exit and the word". On #815's first commit: `bootstrap FAILED: pins + (exit 2)`, exit 1.""" + result = without_git(plain, tmp_path, env={"SHAPE_UPSTREAM_PATH": ""}) + ends(result, 2, REFUSED) + assert "REFUSED git-not-runnable: " in result.stderr, result.stderr + assert "bootstrap FAILED" not in result.stderr, result.stderr + assert " [spec] spec: NOT INITIALIZED." in result.stdout, result.stdout + assert "(c) review authority" in result.stdout, result.stdout + + +def test_no_git_and_a_clone_named_refuses_before_either_line(plain, tmp_path): + """Step (d)'s docstring: a notice that must run git refuses + `git-not-runnable`, exit 2 (#694), and the run stops there, though a + validator had found (the manifest) and one had refused (the pins).""" + a_finding(plain) + upstream = tmp_path / "upstream" + upstream.mkdir() + result = without_git(plain, tmp_path, + env={"SHAPE_UPSTREAM_PATH": str(upstream)}) + assert result.returncode == 2, result.stderr + result.stdout + assert "Traceback" not in result.stderr, result.stderr + assert "REFUSED git-not-runnable: the clone SHAPE_UPSTREAM_PATH names" in ( + result.stderr), result.stderr + assert "bootstrap FAILED" not in result.stderr, result.stderr + assert "bootstrap REFUSED" not in result.stderr, result.stderr + assert "(c) review authority" not in result.stdout, result.stdout + + +@pytest.mark.parametrize("which, code, line", [ + ("plain", 2, REFUSED), ("project", 1, FAILED)], + ids=["refused", "failed"]) +def test_the_ending_follows_step_c_in_a_log_of_both_streams(request, which, + code, line): + """`> log 2>&1`: the ending's line is the log's last, and after step (c)'s + own, as a person reads it. `PYTHONUNBUFFERED` is blanked so the pipe is + block-buffered whoever runs the test.""" + root = request.getfixturevalue(which) + if which == "project": + a_finding(root) + result = run_script(root / "scripts" / "bootstrap.py", cwd=root, + env={"SHAPE_UPSTREAM_PATH": "", + "PYTHONUNBUFFERED": ""}, merge_stderr=True) + assert result.returncode == code, result.stdout + assert result.stdout.endswith(f"{line}\n"), result.stdout + assert (result.stdout.index("(c) review authority") + < result.stdout.index(line)), result.stdout + + +# --- the ending itself, asked of each mix of codes -------------------------- + +@pytest.fixture(scope="module") +def ending(): + """The assembly root's `_end_of_run`, loaded by path.""" + module = load_without_bytecode( + REPO / "templates" / "assembly-root" / "scripts" / "bootstrap.py", + "bootstrap_refusal_exit") + return module._end_of_run + + +@pytest.mark.parametrize("failed, code, said", [ + ([], 0, []), + ([("manifest", 1)], 1, ["bootstrap FAILED: manifest (exit 1)"]), + ([("naming", 1), ("manifest", 1)], 1, + ["bootstrap FAILED: naming (exit 1), manifest (exit 1)"]), + ([("pins", 2)], 2, ["bootstrap REFUSED: pins (exit 2)"]), + ([("naming", 2), ("pins", 2)], 2, + ["bootstrap REFUSED: naming (exit 2), pins (exit 2)"]), + ([("pins", 3)], 1, ["bootstrap FAILED: pins (exit 3)"]), + ([("pins", 127)], 1, ["bootstrap FAILED: pins (exit 127)"]), + ([("pins", 2), ("manifest", 1)], 2, + ["bootstrap FAILED: manifest (exit 1)", + "bootstrap REFUSED: pins (exit 2)"]), + ([("naming", 3), ("manifest", 1), ("pins", 2)], 2, + ["bootstrap FAILED: naming (exit 3), manifest (exit 1)", + "bootstrap REFUSED: pins (exit 2)"]), +], ids=["none", "one finding", "two findings", "one refusal", + "two refusals", "an exit 3", "an exit 127", "refusal first", + "all three"]) +def test_the_ending_says_each_kind_and_exits_by_the_worst(ending, capsys, + failed, code, said): + assert ending(failed) == code + seen = capsys.readouterr() + assert seen.err.splitlines() == said + assert seen.out == ("bootstrap ok\n" if code == 0 else "") diff --git a/tests/test_bootstrap_upstream_notice.py b/tests/test_bootstrap_upstream_notice.py new file mode 100644 index 00000000..e66c9484 --- /dev/null +++ b/tests/test_bootstrap_upstream_notice.py @@ -0,0 +1,182 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The assembly root's offline upstream notice says nothing where it cannot +read an answer, and never ends `make bootstrap` in a traceback (#764, C1). + +THE DEFECT, measured on lane 5's #745 head (f4935e11, Python 3.12.3, git +2.43.0) in a scaffold's recursive clone whose validators all pass: + +- `SHAPE_UPSTREAM_PATH` set to a directory this process may not enter ended + `bootstrap.py` in a `PermissionError` traceback, exit 1. `subprocess` + raised it for the `cwd` of `git rev-parse HEAD`, naming the directory, + which `_git_runs` passes on as not git's (#694). +- Set to a directory UNDER one, `Path.is_dir` raised it, as Python 3.12 and + 3.13 do (3.14 answers False there instead). +- A `.shape-upstream-tip` this process may not read ended the same way, and + one whose bytes are not UTF-8 in a `UnicodeDecodeError`. +- A `contracts/shape-pin.yaml` that is a list ended in an `AttributeError`, + after `validate-pins.py` had refused it (`shape-pin-unreadable`). +- A `contracts/` this process may not enter ended in a `PermissionError` + from the notice's own `is_file`, after the validators. +- A `SHAPE_UPSTREAM_PATH` over the longest name or path the system takes + ended in an `OSError` (`ENAMETOOLONG`) traceback, where Python 3.12 and + 3.13 raise it from `Path.is_dir` too (3.14 answers False). + +The notice's docstring says it is silent where it cannot answer and never +fails the bootstrap; the issue's goal is that `make bootstrap` never ends in +a traceback over it. + +THE RULE NOW. Each is an absent answer: the notice says nothing, the run +ends as its validators end it, and a clone that cannot be read leaves the +cache to answer, as a missing one does. A git that cannot be STARTED is +still `git-not-runnable`, exit 2 (`tests/test_bootstrap_without_git.py`). + +A mode nobody may enter is POSIX's: root reads through it and Windows has no +such mode, so those cases skip there. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from conftest import REPO, load_without_bytecode, run_script + +#: A commit the cache can name that no scaffold is pinned at. +TIP = "0123456789abcdef0123456789abcdef01234567" +#: The first words of the notice's one line, over `TIP`. +NOTICE = (f" shape: the upstream is at {TIP[:12]} and this project is " + "pinned at ") +#: Where a mode of 000 keeps this process out. +LOCKED_OUT = pytest.mark.skipif( + os.name == "nt" or os.geteuid() == 0, + reason="a mode of 000 keeps neither root nor a Windows process out") + + +def bootstrap(root: Path, upstream: str = ""): + """`scripts/bootstrap.py` in `root`, the validators included, with + `SHAPE_UPSTREAM_PATH` set to `upstream` (empty: no clone named).""" + return run_script(root / "scripts" / "bootstrap.py", cwd=root, + env={"SHAPE_UPSTREAM_PATH": upstream}) + + +def ends_ok(result) -> None: + """Exit 0, no traceback, and `bootstrap ok` the last line.""" + assert result.returncode == 0, result.stderr + result.stdout + assert "Traceback" not in result.stderr, result.stderr + assert result.stdout.endswith("\nbootstrap ok\n"), result.stdout + + +@pytest.fixture +def locked(tmp_path): + """A directory nobody may list or enter, holding a directory of its own + (`openRepoShape`). Its mode is given back afterwards, so the basetemp + can be removed.""" + folder = tmp_path / "locked" + (folder / "openRepoShape").mkdir(parents=True) + folder.chmod(0) + yield folder + folder.chmod(0o700) + + +@LOCKED_OUT +@pytest.mark.parametrize("under", [False, True], + ids=["the directory", "a directory under it"]) +def test_an_upstream_path_nobody_may_enter_is_an_absent_answer(project, + locked, under): + """THE ISSUE'S CASE, and its `is_dir` half: exit 0, no traceback, no + notice.""" + upstream = locked / "openRepoShape" if under else locked + result = bootstrap(project, str(upstream)) + ends_ok(result) + assert " shape:" not in result.stdout, result.stdout + + +def over_long_name(folder: Path) -> str: + """A name of 300 characters, over the 255 every system here takes.""" + return f"{folder}/{'a' * 300}" + + +def over_long_path(folder: Path) -> str: + """A path of over 5000 characters in names of 49, over the 4096 Linux + takes (and macOS's 1024).""" + return str(folder) + ("/" + "a" * 49) * 100 + + +@pytest.mark.parametrize("over", [over_long_name, over_long_path], + ids=["a name over the limit", + "a path over the limit"]) +def test_an_over_long_upstream_path_is_an_absent_answer(project, tmp_path, + over): + """`stat` answers `ENAMETOOLONG`, which is an `OSError` and no + `PermissionError`: a notice that caught only the one the issue's case + raises would end `make bootstrap` in a traceback again.""" + result = bootstrap(project, over(tmp_path)) + ends_ok(result) + assert " shape:" not in result.stdout, result.stdout + + +@LOCKED_OUT +def test_the_cache_answers_where_the_clone_cannot_be_read(project, locked): + """An unreadable clone is an ABSENT answer, so the cache is asked next, + as it is when `SHAPE_UPSTREAM_PATH` names nothing.""" + (project / ".shape-upstream-tip").write_bytes(f"{TIP}\n".encode()) + result = bootstrap(project, str(locked)) + ends_ok(result) + assert NOTICE in result.stdout, result.stdout + + +def unreadable(cache: Path) -> None: + """The cache holds `TIP`, and nobody may read it.""" + if os.name == "nt" or os.geteuid() == 0: + pytest.skip("a mode of 000 keeps neither root nor a Windows process " + "out") + cache.write_bytes(f"{TIP}\n".encode()) + cache.chmod(0) + + +def not_utf8(cache: Path) -> None: + """The cache's first byte is 0xff, which no UTF-8 text begins with.""" + cache.write_bytes(b"\xff" + TIP[1:].encode() + b"\n") + + +@pytest.mark.parametrize("make", [unreadable, not_utf8], + ids=["unreadable", "not UTF-8"]) +def test_a_cache_that_cannot_be_read_is_an_absent_answer(project, make): + make(project / ".shape-upstream-tip") + result = bootstrap(project) + ends_ok(result) + assert " shape:" not in result.stdout, result.stdout + + +def test_a_shape_pin_that_is_not_a_mapping_ends_as_the_validators_end_it( + project): + """`validate-pins.py` refuses the list by name, and the run goes on to + step (c) and ends on that refusal, `bootstrap REFUSED: pins (exit 2)`, + exit 2, with no traceback.""" + (project / "contracts" / "shape-pin.yaml").write_bytes(b"- a\n- b\n") + result = bootstrap(project) + assert "Traceback" not in result.stderr, result.stderr + assert "REFUSED shape-pin-unreadable: " in result.stderr, result.stderr + assert "\n(c) review authority\n" in result.stdout, result.stdout + assert result.returncode == 2, result.stdout + + +@LOCKED_OUT +def test_a_contracts_folder_nobody_may_enter_is_an_absent_answer(project, + capsys): + """Asked of the notice itself, in this process: the validators before + it in a run have their own answer to such a folder. It says nothing, + though the cache names a commit the pin is not.""" + notice = load_without_bytecode( + REPO / "templates" / "assembly-root" / "scripts" / "bootstrap.py", + "bootstrap_upstream_notice").shape_upstream_notice + (project / ".shape-upstream-tip").write_bytes(f"{TIP}\n".encode()) + contracts = project / "contracts" + contracts.chmod(0) + try: + notice(project) + finally: + contracts.chmod(0o755) + assert capsys.readouterr().out == ""