From 026280cdcb1694d844a731a3769a0adb45b22587 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 23:34:56 +0000 Subject: [PATCH 01/14] openRepoShape: `--install` records the ref it runs at in `openRepoShape.ref` beside the command it places, and the installed command then fetches setup.sh at that ref, hands setup.sh `--shape-ref` for it and checks the doctor's clone out at it, so a command installed from a release stays on that release while `$OPENREPOSHAPE_REF` still overrides it for one run (#564) An installed command read `$OPENREPOSHAPE_REF` at every run and fell back to main, so from v1.0.0 on, with main carrying v1.1.0 work, a command installed from a release drifted off it within hours. The ref is now NAMED by the variable or by the record, and both places that honoured only the exported variable (the doctor's checkout and setup.sh's `--shape-ref`) honour either. The record is a sidecar file, never an edit to the command: the placed command stays byte for byte the file it was installed from, so the installer's own `(unchanged)` verdict and any copy vendored and checked by sha256 still compare bytes and never see the record. An install with no ref records nothing and removes any record already there, which is the command exactly as before. The ref is checked before anything is placed, and the record is checked when it is read, naming the file and the two ways out. `--version` prints the ref in force. docs/cli.md is regenerated for the `--help` paragraph that says so. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 11 +- openRepoShape | 123 +++++++++++++-- tests/test_openreposhape_command.py | 235 +++++++++++++++++++++++++++- 3 files changed, 355 insertions(+), 14 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c793a3e0..7e81e1af 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -150,11 +150,20 @@ opensoft/openRepoTools', and its own installer places them: curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoTools/main/openRepoTools | bash -s -- --install $OPENREPOSHAPE_REPO owner/name to fetch from (default opensoft/openRepoShape) - $OPENREPOSHAPE_REF the ref to fetch it at (default main) + $OPENREPOSHAPE_REF the ref to fetch it at (default: the ref --install + recorded, else main) $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one +`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the +command it places: $OPENREPOSHAPE_REF, which the one-line installer +(install.sh) sets to the release it installs. The installed command then +stays on that ref: it fetches setup.sh at it, setup.sh clones the standard +at it (`--shape-ref`), and `--doctor` checks its clone out at it. +$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the +ref in force. Installed with no ref, it records none and follows main. + `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, diff --git a/openRepoShape b/openRepoShape index 58bbc3f2..246bf217 100755 --- a/openRepoShape +++ b/openRepoShape @@ -3,7 +3,12 @@ # # openRepoShape — the short way in: `openRepoShape Atlas --org `. # -# Install it once, from anywhere, with no checkout and no clone: +# Install it once, from anywhere, with no checkout and no clone. The newest +# release, pinned to it (`install.sh`, #564): +# +# curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +# +# Or this file at main, by hand, which follows main: # # gh api repos/opensoft/openRepoShape/contents/openRepoShape \ # -H 'Accept: application/vnd.github.raw' | bash -s -- --install @@ -28,9 +33,23 @@ set -euo pipefail # setup.sh reads the same variable as something to `git clone` — see # shape_clone_url below: one name, both uses. REPO="${OPENREPOSHAPE_REPO:-opensoft/openRepoShape}" -REF="${OPENREPOSHAPE_REF:-main}" SELF="${BASH_SOURCE[0]:-$0}" +# THE REF, AND WHETHER IT WAS NAMED (#564). A NAMED ref is one somebody +# chose: setup.sh is told it (`--shape-ref`) and `--doctor` checks its clone +# out at it, where an unnamed `main` leaves both to the default branch. Two +# things name one, and the first wins: `$OPENREPOSHAPE_REF`, set for a run, +# and the ref `--install` RECORDED beside this file (`$SELF.ref`, read by +# `read_recorded_ref` below, once `checked_ref` exists to check it). With +# neither, it is `main`, unnamed, exactly as before there was a record. +REF="${OPENREPOSHAPE_REF:-main}" +REF_NAMED=0 +[ -z "${OPENREPOSHAPE_REF:-}" ] || REF_NAMED=1 + +#: The record's name is the command's own plus this, so the record of +#: `~/.local/bin/openRepoShape` is `~/.local/bin/openRepoShape.ref`. +RECORD_SUFFIX=".ref" + PROJECT="" ORG="" MODE="run" @@ -89,11 +108,20 @@ opensoft/openRepoTools', and its own installer places them: curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoTools/main/openRepoTools | bash -s -- --install $OPENREPOSHAPE_REPO owner/name to fetch from (default opensoft/openRepoShape) - $OPENREPOSHAPE_REF the ref to fetch it at (default main) + $OPENREPOSHAPE_REF the ref to fetch it at (default: the ref --install + recorded, else main) $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one +`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the +command it places: $OPENREPOSHAPE_REF, which the one-line installer +(install.sh) sets to the release it installs. The installed command then +stays on that ref: it fetches setup.sh at it, setup.sh clones the standard +at it (`--shape-ref`), and `--doctor` checks its clone out at it. +$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the +ref in force. Installed with no ref, it records none and follows main. + `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, @@ -288,9 +316,54 @@ machine_report() { say " run: openRepoShape --preflight (offers what is missing; each install asks first)" } +# THE REF THIS INSTALL RAN AT, WRITTEN BESIDE THE COMMAND IT PLACED (#564). +# Without it an installed command read `$OPENREPOSHAPE_REF` at every run and +# fell back to `main`, so a command installed from a release fetched the next +# day's main: the release it was installed from lasted until main moved. +# +# A FILE BESIDE THE COMMAND, NEVER AN EDIT TO IT. The placed command stays +# byte for byte the file it was installed from, so `cmp` against the release's +# own `openRepoShape` still says whether it IS that release: the `(unchanged)` +# verdict `install_commands` gives, and any copy vendored and checked by +# sha256 elsewhere, compare the command's bytes and never see the record. A +# command copied away without its record follows `main` again, and says so in +# `--version`. +# +# THE REF RECORDED IS THE ONE IN FORCE: `$OPENREPOSHAPE_REF`, else the record +# beside the copy being run, so a pinned command re-installing itself stays +# pinned. With neither, any record already there is REMOVED, which is what +# makes an install at main follow main however the command was installed +# before. Every outcome is one line, and no ref and no record is no line at +# all. The record's old value is echoed only when it passes the same shape +# check, because a hand-edited file is not this command's to print. +record_ref() { + local record="$1$RECORD_SUFFIX" was="" + if [ -f "$record" ]; then + was="$(< "$record")" || was="" + case "$was" in + '' | [!A-Za-z0-9]* | *..* | *[!A-Za-z0-9._/-]*) was="another ref" ;; + esac + [ "${#was}" -le 255 ] || was="another ref" + fi + if [ "$REF_NAMED" -eq 0 ]; then + [ -e "$record" ] || return 0 + rm -f -- "$record" + say "openRepoShape: no longer pinned to ${was:-another ref}; it follows main, or \$OPENREPOSHAPE_REF where set (removed $record)" + elif [ "$was" = "$REF" ]; then + say "openRepoShape: already pinned to $REF in $record (unchanged)" + else + printf '%s\n' "$REF" >"$record" + say "openRepoShape: pinned to $REF${was:+ (was $was)}, recorded in $record" + fi +} + install_commands() { local dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" local name target source verb placed=0 + # THE REF IS CHECKED BEFORE ANYTHING IS COLLECTED OR PLACED: it is about + # to be written down and read back by every later run, and a refusal + # after the copy would leave a new command beside an old record. + [ "$REF_NAMED" -eq 0 ] || checked_ref "$REF" collect_commands mkdir -p "$dir" for name in "${INSTALLABLES[@]}"; do @@ -307,6 +380,9 @@ install_commands() { placed=$((placed + 1)) say "$name: $verb" done + # The record belongs to the one command that reads it, and is written + # only once that command is in place. + record_ref "$dir/openRepoShape" # THE COUNT, out loud, and kept at one installable (#92): it costs a line # and is what keeps $INSTALLABLES honest — a person reading N lines cannot # tell whether an N+1th was meant to be there. The sentence that made @@ -354,7 +430,8 @@ find_python() { # A CHECKOUT OF THE STANDARD, made the way `setup.sh` self-bootstraps one: # `git clone` of `shape_clone_url`, into the temporary directory `workdir` -# owns, checked out at $OPENREPOSHAPE_REF when one was named. Sets $SHAPE_DIR. +# owns, checked out at $REF when one was named: by $OPENREPOSHAPE_REF, or by +# the record `--install` left beside this file (#564). Sets $SHAPE_DIR. # # WITHOUT `--depth 1`, WHICH IS THE ONE DEVIATION FROM setup.sh AND IS FORCED. # A shallow clone has exactly ONE commit, and the doctor's shape-currency row @@ -378,7 +455,7 @@ shape_checkout() { die "could not clone $REPO into a temporary directory, so there is no standard to check against. \`--doctor\` needs one: run it from a checkout of openRepoShape, or make the clone work." - if [ -n "${OPENREPOSHAPE_REF:-}" ]; then + if [ "$REF_NAMED" -eq 1 ]; then checked_ref "$REF" # ` --`, never `-- `: a `--` BEFORE the argument makes git # read it as a PATHSPEC and try to restore a file of that name from @@ -397,19 +474,44 @@ shape_checkout() { # CHARACTER next, because a newline in it could forge a line of output before # this script has said a word; and only then the SHAPE, which is safe to echo # back because it has passed both. +# +# WHOSE REF IT IS, AND THE WAY OUT, ARE ARGUMENTS (#564): `$OPENREPOSHAPE_REF` +# by default, or the record `--install` wrote, whose refusal also says how to +# replace it. An EMPTY ref is refused too, which no caller could reach before +# the record: an empty variable is no ref at all, while an empty file is one. checked_ref() { - local ref="$1" + local ref="$1" what="${2:-\$OPENREPOSHAPE_REF}" out="${3:+ $3}" + [ -n "$ref" ] || + die "$what is empty, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" if [ "${#ref}" -gt 255 ]; then - die "\$OPENREPOSHAPE_REF is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters." + die "$what is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters.$out" fi case "$ref" in - *[![:print:]]*) die "\$OPENREPOSHAPE_REF carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`." ;; + *[![:print:]]*) die "$what carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" ;; esac case "$ref" in - [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "\$OPENREPOSHAPE_REF is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'." ;; + [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "$what is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'.$out" ;; esac } +# THE RECORD `--install` LEFT BESIDE THIS FILE, READ ONCE, BEFORE ANY RUN +# (#564). Only when no `$OPENREPOSHAPE_REF` was set, which is what lets that +# variable win for a run, and only beside a FILE: a script arriving on stdin +# has no place of its own to have been installed at. CHECKED HERE, AT ONCE, +# rather than where the ref is used: it is a file on disk that every run +# reads, `--version` prints it, and a value nobody typed is not echoed until +# it has passed. `$(< file)` is bash's own read, so no `cat` is needed. +read_recorded_ref() { + local record="$SELF$RECORD_SUFFIX" + [ "$REF_NAMED" -eq 0 ] && [ -f "$SELF" ] && [ -f "$record" ] || return 0 + REF="$(< "$record")" || + die "could not read $record, the ref \`--install\` recorded. Run the installer again, or delete that file to follow main." + checked_ref "$REF" "the ref recorded in $record" \ + "Run the installer again to record a release, or delete that file to follow main." + REF_NAMED=1 +} +read_recorded_ref + # `--doctor`: RUN THE STANDARD'S OWN SCRIPT, never a reimplementation here. # Beside a checkout -- this file sitting in one, which is how a developer runs # it -- that checkout answers, and nothing is fetched at all. Installed, or @@ -671,7 +773,8 @@ ARGS=() [ -z "$PROJECT" ] || ARGS+=(--project "$PROJECT") # Fetched at $REF, so setup.sh self-bootstraps its checkout at $REF too: # otherwise the script that runs and the standard it clones are two commits. -[ -z "${OPENREPOSHAPE_REF:-}" ] || ARGS+=(--shape-ref "$REF") +# Whenever the ref was NAMED, by the variable or by the record (#564). +[ "$REF_NAMED" -eq 0 ] || ARGS+=(--shape-ref "$REF") ARGS+=(${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"}) # $OPENREPOSHAPE_SETUP_SH is the OFFLINE TEST PATH: a setup.sh already on disk diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index d6b94f82..59cb0c00 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -34,7 +34,7 @@ import pytest -from conftest import REPO, WINDOWS_SKIP +from conftest import REPO, WINDOWS_SKIP, git COMMAND = REPO / "openRepoShape" SETUP = REPO / "setup.sh" @@ -113,7 +113,8 @@ def command_env(home: Path | None = None, env: dict | None = None, def run_cmd(*args: str, home: Path | None = None, env: dict | None = None, - setup_sh: Path | None = SETUP) -> subprocess.CompletedProcess: + setup_sh: Path | None = SETUP, + command: Path = COMMAND) -> subprocess.CompletedProcess: """The command, run from its file, with that environment. `input=""` means stdin is a pipe rather than a terminal, which is what the @@ -121,8 +122,11 @@ def run_cmd(*args: str, home: Path | None = None, env: dict | None = None, controlling terminal either (#492): setup asks at that terminal when stdin is not one, and the terminal pytest was started from is nobody's answer. + + `command` is this checkout's file unless a test names an INSTALLED copy, + which is how the record `--install` leaves beside one is read (#564). """ - return subprocess.run(["bash", str(COMMAND), *args], capture_output=True, + return subprocess.run(["bash", str(command), *args], capture_output=True, text=True, check=False, input="", env=command_env(home, env, setup_sh), start_new_session=True) @@ -1002,3 +1006,228 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, assert stat.S_IMODE(target.stat().st_mode) == 0o755, name assert target.read_bytes() == (REPO / name).read_bytes(), name assert f"{name}: installed at" in result.stdout + + +# --- the ref `--install` records, and the installed command runs (#564) ----- + +#: A ref for an install to record. Nothing fetches it: every install below +#: runs from this checkout's file, which copies its own bytes, and every run +#: after it is offline. `OTHER` is what `$OPENREPOSHAPE_REF` names for one +#: run, over the record. +PINNED = "v9.9.9" +OTHER = "other" + + +def install_at(tmp_path, ref=None, command=COMMAND): + """`--install` from `command` into `/bin`, with + `$OPENREPOSHAPE_REF` set to `ref` when one is given; the result, and the + path of the command it places.""" + bin_dir = tmp_path / "bin" + env = {"OPENREPOSHAPE_BIN_DIR": str(bin_dir)} + if ref is not None: + env["OPENREPOSHAPE_REF"] = ref + return (run_cmd("--install", home=tmp_path, env=env, command=command), + bin_dir / "openRepoShape") + + +def record_of(installed: Path) -> Path: + """Where `--install` records the ref of the command at `installed`.""" + return installed.with_name(installed.name + ".ref") + + +def version_of(installed: Path, env: dict | None = None) -> str: + result = run_cmd("--version", command=installed, env=env) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def test_install_records_the_ref_beside_a_command_it_leaves_unchanged( + tmp_path): + """THE RECORD IS A FILE BESIDE THE COMMAND, AND THE COMMAND IS THE FILE. + + The placed bytes must still be the bytes it was installed from, because + everything that asks whether an installed command IS a release compares + bytes: this installer's own `(unchanged)` verdict, and a copy vendored + elsewhere and checked by sha256 against the release's own file. So the + ref goes in `openRepoShape.ref`, said on its own line between the + command's line and the count, and `--version` then names it with no + variable set, while the variable still wins for the one run it is set + for. + """ + result, installed = install_at(tmp_path, PINNED) + assert result.returncode == 0, result.stderr + record = record_of(installed) + assert record.read_bytes() == f"{PINNED}\n".encode("ascii") + assert installed.read_bytes() == COMMAND.read_bytes(), ( + "the command must stay byte for byte the file it was installed from") + assert result.stdout.splitlines()[:3] == [ + f"openRepoShape: installed at {installed}", + f"openRepoShape: pinned to {PINNED}, recorded in {record}", + f"openRepoShape: 1 of 1 placed in {installed.parent}"] + assert version_of(installed) == \ + f"openRepoShape (opensoft/openRepoShape @ {PINNED})" + assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}) == \ + f"openRepoShape (opensoft/openRepoShape @ {OTHER})" + + +def test_an_install_with_no_ref_records_nothing_and_follows_main(tmp_path): + """No ref at install time is the install as it always was: the same + lines, no record, and `main`, unnamed, in `--version`.""" + result, installed = install_at(tmp_path) + assert result.returncode == 0, result.stderr + assert not record_of(installed).exists() + assert "pinned" not in result.stdout + assert version_of(installed) == \ + "openRepoShape (opensoft/openRepoShape @ main)" + + +def test_installing_again_keeps_moves_or_drops_the_record(tmp_path): + """Idempotent at the same ref, moved by a new one, dropped by none. + + A second install at the same ref changes neither file and says so twice. + A newer ref replaces the record and names the one it replaced, which is + what an upgrade looks like. The installed command re-installing itself + with no variable set keeps ITS ref, because that is the ref it runs at. + And an install with no ref at all removes the record, so a command put + back at main follows main however it was installed before. + """ + install_at(tmp_path, PINNED) + same, installed = install_at(tmp_path, PINNED) + record = record_of(installed) + assert same.returncode == 0, same.stderr + assert f"openRepoShape: already pinned to {PINNED} in {record} " \ + "(unchanged)" in same.stdout + assert same.stdout.count("(unchanged)") == 2, same.stdout + + newer, _ = install_at(tmp_path, "v10.0.0") + assert f"openRepoShape: pinned to v10.0.0 (was {PINNED}), recorded in " \ + f"{record}" in newer.stdout + itself, _ = install_at(tmp_path, command=installed) + assert "openRepoShape: already pinned to v10.0.0" in itself.stdout + assert record.read_text(encoding="utf-8") == "v10.0.0\n" + + dropped, _ = install_at(tmp_path) + assert dropped.returncode == 0, dropped.stderr + assert "openRepoShape: no longer pinned to v10.0.0; it follows main" \ + in dropped.stdout + assert not record.exists() + assert version_of(installed).endswith("@ main)") + + +def test_a_ref_that_is_not_one_is_refused_before_anything_is_placed( + tmp_path): + """The ref is about to be written down and read by every later run, so + it is checked first, the way the doctor checks it before a checkout.""" + result, installed = install_at(tmp_path, "bad..ref") + assert result.returncode == 2, result.stdout + result.stderr + assert "$OPENREPOSHAPE_REF is 'bad..ref'" in result.stderr + assert not installed.parent.exists(), "a refused install placed something" + + +@pytest.mark.parametrize("text, shown", [ + pytest.param(b"", "is empty", id="empty"), + pytest.param(b"-x\n", "is '-x'", id="an-option"), + pytest.param(b"v1..v2\n", "is 'v1..v2'", id="a-range"), + pytest.param(b"v1\nforged\n", "carries a control character", + id="two-lines"), + pytest.param(b"a" * 256 + b"\n", "is 256 characters long", + id="over-long"), +]) +def test_a_record_that_is_not_a_ref_is_refused_by_name(tmp_path, text, shown): + """A record is a file on disk that every run reads, so it is checked + when it is read, before `--version` can print it, and the refusal names + the file and the two ways out. The control character and the over-long + value are refused without being echoed. `$OPENREPOSHAPE_REF`, set, is + the way past it for one run, because then the record is not read.""" + _result, installed = install_at(tmp_path, PINNED) + record_of(installed).write_bytes(text) + result = run_cmd("--version", command=installed) + assert result.returncode == 2, result.stdout + result.stderr + assert f"the ref recorded in {record_of(installed)} {shown}" \ + in result.stderr + assert "Run the installer again to record a release, or delete that " \ + "file to follow main." in result.stderr + assert "forged" not in result.stderr and "a" * 256 not in result.stderr + assert result.stdout == "" + assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}).endswith( + f"@ {OTHER})") + + +#: WHICH REF EACH LATER RUN USES: recorded at install, then overridden by the +#: variable for one run, then neither. The third column is what the run must +#: be handed; `None` is `main`, unnamed, which nothing is told. +RECORDED_RUNS = [ + pytest.param(PINNED, None, PINNED, id="the-record"), + pytest.param(PINNED, OTHER, OTHER, id="the-variable-over-the-record"), + pytest.param(None, None, None, id="no-record"), +] + + +@pytest.mark.parametrize("recorded, variable, expected", RECORDED_RUNS) +def test_setup_sh_clones_the_standard_at_the_ref_in_force( + tmp_path, recorded, variable, expected): + """`--shape-ref` reaches setup.sh whenever a ref was NAMED, and the + record names one: the setup.sh this command fetched at that ref then + clones the standard at the same ref, not at main.""" + _result, installed = install_at(tmp_path, recorded) + stub = tmp_path / "setup.sh" + stub.write_text(STUB_SETUP_SH, encoding="utf-8") + result = run_cmd("--preflight", command=installed, setup_sh=stub, + env=None if variable is None + else {"OPENREPOSHAPE_REF": variable}) + assert result.returncode == 0, result.stdout + result.stderr + match = re.search(rf"{STUB_MARKER} in .+ args: (.*)", result.stdout) + assert match, result.stdout + result.stderr + forwarded = match.group(1).split() + if expected is None: + assert "--shape-ref" not in forwarded, forwarded + else: + at = forwarded.index("--shape-ref") + assert forwarded[at + 1] == expected, forwarded + + +def standard_at_three_refs(tmp_path) -> Path: + """A bare repository whose `shape-doctor.py` says which ref it is. + + Three commits: the `PINNED` tag, the `OTHER` branch and `main`, each + with a doctor that prints its own name and nothing else, so a run + reports which ref the command checked its clone out at. + """ + seed = tmp_path / "seed" + seed.mkdir() + git("init", "-q", "-b", "main", cwd=seed) + + def commit(name): + (seed / "shape-doctor.py").write_text( + f"print('STUB DOCTOR AT {name}')\n", encoding="utf-8") + git("add", "-A", cwd=seed) + git("commit", "-q", "-m", name, cwd=seed) + + commit(PINNED) + git("tag", PINNED, cwd=seed) + git("switch", "-q", "-c", OTHER, cwd=seed) + commit(OTHER) + git("switch", "-q", "main", cwd=seed) + commit("main") + bare = tmp_path / "standard.git" + git("clone", "-q", "--bare", str(seed), str(bare), cwd=tmp_path) + return bare + + +@pytest.mark.parametrize("recorded, variable, expected", RECORDED_RUNS) +def test_the_doctor_checks_its_clone_out_at_the_ref_in_force( + tmp_path, recorded, variable, expected): + """The installed `--doctor` clones the standard, and checks the clone + out at the ref in force: the record, the variable over it, or neither, + which leaves the clone on its default branch.""" + _result, installed = install_at(tmp_path, recorded) + temporary = tmp_path / "tmp" + temporary.mkdir() + env = {"OPENREPOSHAPE_REPO": str(standard_at_three_refs(tmp_path)), + "TMPDIR": str(temporary)} + if variable is not None: + env["OPENREPOSHAPE_REF"] = variable + result = run_cmd("--doctor", str(tmp_path), command=installed, env=env) + assert result.returncode == 0, result.stdout + result.stderr + assert f"STUB DOCTOR AT {expected or 'main'}" in result.stdout, ( + result.stdout + result.stderr) From d0cdbf4c7a500fa912d5e2554e799cf8dfeba1e5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 23:35:06 +0000 Subject: [PATCH 02/14] install.sh: one line, `curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash`, installs the newest release's openRepoShape pinned to that release, installs git-filter-repo with pipx where pipx is and it is not or prints the one command that would, and ends with `openRepoShape --preflight`; README, the handbook and the v1.0.1 release notes lead with it (#564) Brett Heap, 2026-10-14: "we need it to be more simple to install ... now and after, we need a simple install process", and "v1.0.1 soon". install.sh is attached to every release, so `releases/latest/download/` is always the newest. It reads the newest tag from github.com's `releases/latest` redirect with no gh and no login, asking `gh api` only when the redirect names none; takes a named release as its one argument; fetches that release's `openRepoShape`, checks it is the command, and runs its `--install` with `$OPENREPOSHAPE_REF` set to the release, then asks the installed command's `--version` that it stayed there. Every step says what it did in one line. It never runs sudo. Exit 0 installed and ready, 1 installed but the preflight or the pin needs attention, 2 nothing installed; git-filter-repo never moves it. Everything runs from a `main` called on the last line, so a cut-off download runs nothing. README.md: step 3 and the worked example lead with the one line, the gh api and raw-URL lines stay as the by-hand way at main, and Layout gains install.sh; the cap moves 1422 -> 1450, with the reason in test_readme_is_short_enough_to_be_read. docs/handbook.html says the same, names the new README blob and adds the (#564) clause. install.sh joins SHIPPED_BASH and the macOS job's `/bin/bash -n` step, and one test holds the one line byte for byte in every file that carries it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 4 + README.md | 42 ++- docs/handbook.html | 47 ++- docs/release-notes/v1.0.1.md | 65 ++++ install.sh | 361 +++++++++++++++++++++ tests/test_install_sh.py | 596 +++++++++++++++++++++++++++++++++++ tests/test_repo_hygiene.py | 57 +++- 7 files changed, 1143 insertions(+), 29 deletions(-) create mode 100644 docs/release-notes/v1.0.1.md create mode 100755 install.sh create mode 100644 tests/test_install_sh.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e5e47255..08a57826 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -282,11 +282,15 @@ jobs: # for the reason this one exists — those two are what a stock Mac's bash # 3.2 has to parse, and the repository that ships a file is the one that # can say so. + # + # `install.sh` joined the list in v1.0.1 (#564): the one-line install, + # `curl … | bash` on a Mac, hands it to this same `/bin/bash`. - name: /bin/bash parses every bash entry point run: | /bin/bash --version /bin/bash -n setup.sh /bin/bash -n openRepoShape + /bin/bash -n install.sh - name: tests run: python3 -m pytest tests -q diff --git a/README.md b/README.md index f3b378dc..147b3cb5 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,10 @@ is on no `PATH` by default, and its installer finishes by printing the line that puts it there — run that line, or the next shell cannot find `brew`. Then `brew install gh`, and `brew install git-filter-repo` only if you are ADOPTING an existing repository rather than scaffolding a new one. The stock `/bin/bash` is -3.2 and is enough: `setup.sh` and the `openRepoShape` command use no syntax -newer than that, and CI runs the whole suite on `macos-latest`, parsing both -there with `/bin/bash` itself. That the login shell is zsh changes nothing — -both entry points are run under `bash` explicitly. +3.2 and is enough: `setup.sh`, `install.sh` and the `openRepoShape` command use +no syntax newer than that, and CI runs the whole suite on `macos-latest`, +parsing all three there with `/bin/bash` itself. That the login shell is zsh +changes nothing — all three entry points are run under `bash` explicitly. @@ -225,7 +225,27 @@ offer to configure git's credential helper, or run `gh auth setup-git`: the first commits go up with a plain `git push` over HTTPS, and without it the three repositories are created and the push fails. -**3. Install the `openRepoShape` command**, once: +**3. Install the `openRepoShape` command**, once, in one line: + +```sh +curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +``` + +`install.sh` is attached to every release, so that line installs the newest: +it reads which one that is from github.com's `releases/latest` redirect (no +`gh`, no login), installs that release's command PINNED to it, installs +`git-filter-repo` with `pipx` when `pipx` is there and it is not, or prints +the one command that would, and ends with `openRepoShape --preflight`. It +never runs `sudo`. `| bash -s -- v1.0.1` installs a named release instead. +Exit 0 is installed and ready, 1 installed but the preflight or the pin needs +you, 2 nothing installed. Run the same line again to upgrade. + +**Pinned** means the command stays on its release while main moves on: +`--install` records the ref in `openRepoShape.ref` beside the command, and the +command fetches `setup.sh`, clones the standard and runs `--doctor` at it. +`$OPENREPOSHAPE_REF` overrides it for one run; `--version` prints the ref. + +By hand, the same command at main, which records no ref and follows main: ```sh gh api repos/opensoft/openRepoShape/contents/openRepoShape \ @@ -239,7 +259,7 @@ curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRep | bash -s -- --install ``` -It installs ONE command into `~/.local/bin`, idempotently — a second run +Each way installs ONE command into `~/.local/bin`, idempotently — a second run prints `unchanged` — and prints the `export PATH=…` line if that directory is not on `PATH`. The estate verbs `park` and `resume`, for [carrying in-flight work](#carrying-in-flight-work-to-another-workstation), are @@ -380,7 +400,14 @@ records as whose act the election was, and it defaults to the `gh` login. #### Install the command -Two ways in, the short one first. Install `openRepoShape` as a command: +Two ways in, the short one first. Install `openRepoShape` as a command, the +newest release and pinned to it, as step 3 above does: + +```sh +curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +``` + +or by hand, at main, through the API: ```sh gh api repos/opensoft/openRepoShape/contents/openRepoShape \ @@ -1389,6 +1416,7 @@ scripts/path_classify.py the classifier over the path policy scripts/shape_materialize.py the ONE materializer, used by both tools scripts/validate-repository-naming.py openRepoShape installs itself as a command; runs setup.sh +install.sh the one-line install: a release, pinned setup.sh the shim: find an interpreter, hand over setup-project.py THE FLOW, and the Windows way in (no bash there) scaffold-project.py creates the three repositories and the pins diff --git a/docs/handbook.html b/docs/handbook.html index 4e01a52b..377f934a 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -485,11 +485,11 @@

openRepoShape

convention, as data and as running code.

The fastest path — macOS, Linux, WSL2

-
curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRepoShape \
-    | bash -s -- --install
-openRepoShape --preflight
+          
curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
 openRepoShape <Project> --org <your-org>
-

That first line installs ONE command into ~/.local/bin. The +

That first line installs ONE command into ~/.local/bin: the + newest release, pinned to it, with git-filter-repo where pipx can + install it, and it ends with openRepoShape --preflight. The estate verbs park and resume, for carrying in-flight work to another workstation, are opensoft/openRepoTools’ and have a one-liner of @@ -672,11 +672,11 @@

What this machine needs

brew install gh    # macOS, or Linux with Homebrew

Then brew install git-filter-repo only if you are ADOPTING an existing repository rather than scaffolding a new one.

-

The stock /bin/bash is 3.2 and is enough: setup.sh and the - openRepoShape command use no syntax newer than that, and CI runs the whole - suite on macos-latest, parsing both there with /bin/bash itself. - That the login shell is zsh changes nothing — both entry points are run under - bash explicitly.

+

The stock /bin/bash is 3.2 and is enough: setup.sh, + install.sh and the openRepoShape command use no syntax newer than + that, and CI runs the whole suite on macos-latest, parsing all three there with + /bin/bash itself. That the login shell is zsh changes nothing — all three + entry points are run under bash explicitly.

The tool offers the rest

The preflight names what is missing and, for gh, its login and its credential helper, prints the command for THIS machine and asks once — Type yes to continue: — @@ -755,11 +755,29 @@

Make one machine setting first

Install the openRepoShape command, once

Install the openRepoShape command, once, inside WSL

Fetch the flow, in the directory the new project should land in

+
curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
+

install.sh is attached to every release, so + that line installs the newest: it reads which one that is from github.com’s + releases/latest redirect (no gh, no login), installs that + release’s command PINNED to it, installs git-filter-repo with + pipx when pipx is there and it is not, or prints the one command + that would, and ends with openRepoShape --preflight. It never runs + sudo. | bash -s -- v1.0.1 installs a named release instead. Exit + 0 is installed and ready, 1 installed but the preflight or the pin needs you, 2 nothing + installed. Run the same line again to upgrade.

+

Pinned means the command stays on its + release while main moves on: --install records the ref in + openRepoShape.ref beside the command, and the command fetches + setup.sh, clones the standard and runs --doctor at it. + $OPENREPOSHAPE_REF overrides it for one run; --version prints the + ref.

+

By hand, the same command at main, which records no ref and follows main:

gh api repos/opensoft/openRepoShape/contents/openRepoShape \
     -H 'Accept: application/vnd.github.raw' | bash -s -- --install

Or, where raw downloads are not blocked, the same bytes over HTTPS:

curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRepoShape \
     | bash -s -- --install
+

By hand, the same command at main, which records no ref and follows main:

curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRepoShape \
     | bash -s -- --install

Or, where raw downloads are blocked, the same bytes through the API:

@@ -774,13 +792,13 @@

Fetch the flow, in the directory the new project should an exhausted stdin, so it cannot ask for the one yes before three repositories exist — it refuses instead. Downloading the file first leaves stdin attached to the terminal you are typing at.

-

It installs ONE command into ~/.local/bin, +

Each way installs ONE command into ~/.local/bin, idempotently — a second run prints unchanged — and prints the export PATH=… line if that directory is not on PATH. The estate verbs park and resume, for carrying in-flight work, are opensoft/openRepoTools’ and have an install line of their own.

-

It installs ONE command into ~/.local/bin, +

Each way installs ONE command into ~/.local/bin, idempotently — a second run prints unchanged — and prints the export PATH=… line if that directory is not on PATH. It is one file at the repository root and implements nothing: @@ -1750,6 +1768,7 @@

Layout of this repository

scripts/shape_materialize.py
the ONE materializer, used by both tools
scripts/validate-repository-naming.py
answers the naming question for one name
openRepoShape
installs itself as a command; runs setup.sh
+
install.sh
the one-line install: a release, pinned
setup.sh
the shim: find an interpreter, hand over
setup-project.py
THE FLOW, and the Windows way in (no bash there)
scaffold-project.py
creates the three repositories and the pins
@@ -1886,9 +1905,11 @@

For AI assistants

adoption’s --project as the source’s own name, added (#499); the merged-away verdict, whose row apply drops, added (#502); the line-ending block an adoption appends to a root .gitattributes the source keeps, - and its verification as the source’s bytes plus that block, added (#471) — a page + and its verification as the source’s bytes plus that block, added (#471); the one-line + install, install.sh, which installs the newest release pinned to it, and the + command’s openRepoShape.ref record, added (#564) — a page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob f3b378dc5704de79ee63972d0ea0be14dc693209. Every command, flag, exit code + README.md blob 147b3cb5c1d91c96196d6cef09ba984cfa0f6576. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

diff --git a/docs/release-notes/v1.0.1.md b/docs/release-notes/v1.0.1.md new file mode 100644 index 00000000..a5c5e3cb --- /dev/null +++ b/docs/release-notes/v1.0.1.md @@ -0,0 +1,65 @@ +# openRepoShape v1.0.1 + +v1.0.1 is v1.0.0 plus one change: openRepoShape installs in one line, and the +command it installs stays on the release it was installed from (#564). + +## What 1.0.1 adds + +- **The one-line install.** `install.sh` is attached to every release, so + this line always installs the newest one: + + ```sh + curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash + ``` + + It reads which release is the newest from github.com's `releases/latest` + redirect, with no `gh` and no login. It fetches that release's + `openRepoShape` and installs it into `~/.local/bin`, or + `$OPENREPOSHAPE_BIN_DIR`, pinned to the release. It installs + `git-filter-repo` 2.47.0 with `pipx` when `pipx` is there and + `git-filter-repo` is not, and otherwise prints the one command that + installs it. It ends with `openRepoShape --preflight`. It never runs `sudo` + and installs nothing else. It exits 0 when the command is installed and the + preflight passed, 1 when the command is installed but the preflight did not + pass or the command does not stay on the release, and 2 when it installed + nothing. +- **An installed command that stays on its release.** `openRepoShape + --install` records the ref it runs at, `$OPENREPOSHAPE_REF`, in + `openRepoShape.ref` beside the command it places. The installed command then + fetches `setup.sh` at that ref, `setup.sh` clones the standard at it + (`--shape-ref`), and `--doctor` checks its clone out at it. In v1.0.0 an + installed command fetched main on every run unless `$OPENREPOSHAPE_REF` was + exported. `$OPENREPOSHAPE_REF` set for one run still overrides the record, + and `openRepoShape --version` prints the ref in force. An install with no + ref records nothing, removes any record already there, and follows main. The + installed command is byte for byte the release's own file. + +## Install and upgrade + +- **The newest release:** the line above. Run it again to upgrade to the + newest release. +- **A named release:** + `curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash -s -- v1.0.1`, + or `bash install.sh v1.0.1` from a checkout or a download. +- **What is installed:** `openRepoShape --version` prints + `openRepoShape (opensoft/openRepoShape @ v1.0.1)`. +- **By hand, at main:** the README's + `gh api repos/opensoft/openRepoShape/contents/openRepoShape … | bash -s -- --install` + line still installs main's command, which records no ref and follows main. +- **Windows:** `install.sh` is bash, for macOS, Linux and WSL2. Native + Windows still starts with `py setup-project.py`, as the README says. +- **A project's pin** on the standard works as before. Follow + [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.0.1 release + commit. + +## A script that runs `--install` + +A script that runs `openRepoShape --install` with `$OPENREPOSHAPE_REF` +exported now pins the installed command to that value. If the value is not a +ref the command should run, unset it for the install. To keep the command on a +commit or a tag, set it to that commit or tag. + +## Known limits + +v1.0.1 changes nothing else, so its known limits are v1.0.0's: +[Known limits in v1.0.0](v1.0.0.md#known-limits). diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..8713a67a --- /dev/null +++ b/install.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# install.sh — openRepoShape in one line, pinned to a release (#564): +# +# curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +# +# A named release instead of the newest: `… | bash -s -- v1.0.1`. From a +# checkout or a download, the same: `bash install.sh []`. Running the +# same line again upgrades to the newest release. This file is attached to +# every GitHub release, which is what makes that URL always the newest one. +# +# FOUR STEPS, IN THIS ORDER, EACH SAYING IN A LINE WHAT IT DID: +# +# 1. THE RELEASE: the one named, or the newest, read from the redirect +# github.com answers `releases/latest` with. That needs no `gh`, no token +# and no login, because the repository is public; `gh api` is asked only +# when the redirect names no release and `gh` is installed. +# 2. THAT RELEASE'S `openRepoShape`, fetched into a temporary directory, +# checked to be the command, and installed by its own `--install` with +# `$OPENREPOSHAPE_REF` set to the release. `--install` records the ref +# beside the command it places, and the command then stays on it. +# 3. GIT-FILTER-REPO, which `adopt-project.py` needs and nothing else does: +# said when it is there; installed with `pipx` when pipx is there and it +# is not; otherwise the one command that installs it, printed. +# 4. THE INSTALLED COMMAND'S `--preflight`, LAST, and its verdict. +# +# IT NEVER RUNS sudo AND INSTALLS NOTHING ELSE. The command and, through pipx, +# git-filter-repo are the whole of what it installs. The preflight it ends +# with offers what is missing behind a typed `yes`, at the person's own +# terminal, exactly as `openRepoShape --preflight` always has. +# +# EXIT STATUS: +# 0 installed, pinned to the release, and the preflight passed; +# 1 installed, but the preflight did not pass, or the installed command +# does not stay on the release (one older than v1.0.1 cannot record +# it): the lines above say which; +# 2 refused before anything was installed: a version that is not a ref, no +# release found, a fetch that failed, or a file that is not the command. +# git-filter-repo never changes it: only adopting needs it, and +# `adopt-project.py` refuses with its own install command where it is missing. +# +# EVERYTHING RUNS FROM `main`, CALLED ON THE LAST LINE. Under `curl … | bash` +# bash reads this script from the pipe as it goes, so a download cut off +# part-way would run whatever had arrived. Inside a function nothing runs +# until the last line has arrived, and by then the pipe holds nothing a child +# could read in its place. +# +# BASH 3.2, which is /bin/bash on every Mac: no `${x,,}`, no `declare -A`, +# no `mapfile`; an empty array never meets `set -u`. + +set -euo pipefail + +REPO="${OPENREPOSHAPE_REPO:-opensoft/openRepoShape}" + +#: The version CI installs (`.github/workflows/tests.yml`), so what this +#: installs is what the adoption suites ran against. +FILTER_REPO_PIN="git-filter-repo==2.47.0" + +WORKDIR="" + +#: What `latest_from_redirect` and `latest_from_gh` found, and when they did +#: not, why. Globals rather than output, because a function run inside +#: `$(…)` is a subshell and could say only one of the two. +FOUND="" +FAULT="" + +say() { printf '%s\n' "$*"; } +die() { printf '\nREFUSED: %s\n' "$1" >&2; exit "${2:-2}"; } + +usage() { + cat <<'USAGE' +install.sh: install openRepoShape, pinned to a release. + + curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash + curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash -s -- v1.0.1 + bash install.sh [] + +With no it installs the newest release. It fetches that release's +`openRepoShape`, installs it into ~/.local/bin pinned to the release, installs +git-filter-repo with pipx when pipx is there and git-filter-repo is not (or +prints the command that installs it), and ends with `openRepoShape +--preflight`. It never runs sudo. Run the same line again to upgrade. + +Exit 0: installed, pinned, and the preflight passed. 1: installed, but the +preflight did not pass, or the command does not stay on the release. 2: +refused, and nothing was installed. + + $OPENREPOSHAPE_REPO owner/name to install from (default opensoft/openRepoShape) + $OPENREPOSHAPE_BIN_DIR where the command goes (default ~/.local/bin) +USAGE +} + +# THE REPOSITORY, AS AN OWNER/NAME, because every URL below is built from it. +# The launcher also takes a URL or a path in this variable, for the clone +# setup.sh makes; there is no release at either, so this refuses them by +# name. A control character is refused before anything is echoed. +checked_repo() { + case "$REPO" in + *[![:print:]]*) die "\$OPENREPOSHAPE_REPO carries a control character; it must be an owner/name on github.com, such as opensoft/openRepoShape." ;; + esac + case "$REPO" in + [A-Za-z0-9]*/[A-Za-z0-9._-]*) ;; + *) die "\$OPENREPOSHAPE_REPO is '$REPO', which is not an owner/name on github.com, such as opensoft/openRepoShape: this installer builds github.com URLs from it." ;; + esac + case "$REPO" in + */*/* | *[!A-Za-z0-9._/-]* | */. | */..) die "\$OPENREPOSHAPE_REPO is '$REPO', which is not an owner/name on github.com, such as opensoft/openRepoShape: this installer builds github.com URLs from it." ;; + esac +} + +# THE LAUNCHER'S `checked_ref`, TRANSCRIBED: the version reaches a URL here +# and `git checkout` once the command runs, so it is held to the same three +# checks in the same order (length, then a control character, then the +# shape, which is the only one that echoes it), plus the empty value. Not +# shared, because this file arrives alone down a pipe. +checked_version() { + local ref="$1" what="$2" + [ -n "$ref" ] || die "$what is empty. Name a release, such as v1.0.1, or none for the newest." + if [ "${#ref}" -gt 255 ]; then + die "$what is ${#ref} characters long; a release tag, branch or commit is at most 255 characters." + fi + case "$ref" in + *[![:print:]]*) die "$what carries a control character, which is not a release tag, branch or commit." ;; + esac + case "$ref" in + [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "$what is '$ref', which is not a release tag, branch or commit: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'." ;; + esac +} + +# THE NEWEST RELEASE, FROM THE REDIRECT. `releases/latest` answers 302 with +# `location: …/releases/tag/`; a repository with no release yet answers +# with `…/releases`, which names no tag and is no answer. The header name is +# matched in any case (HTTP/2 sends it in lower case, HTTP/1.1 need not), +# without `${x,,}`, which bash 3.2 does not have. +latest_from_redirect() { + local url="$1" headers line value status=0 + FOUND="" + command -v curl >/dev/null 2>&1 || { + FAULT="curl is not installed" + return 1 + } + headers="$(curl -fsSI "$url" 2>/dev/null)" || status=$? + if [ "$status" -ne 0 ]; then + FAULT="curl failed, exit $status" + return 1 + fi + while IFS= read -r line; do + line="${line%$'\r'}" + case "$line" in + [Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:*) + value="${line#*:}" + while :; do + case "$value" in + ' '* | $'\t'*) value="${value#?}" ;; + *) break ;; + esac + done + case "$value" in + */releases/tag/?*) + FOUND="${value##*/releases/tag/}" + return 0 + ;; + esac + case "$value" in + *[![:print:]]*) FAULT="it redirects to a location that names no release" ;; + *) FAULT="it redirects to $value, which names no release" ;; + esac + return 1 + ;; + esac + done <<<"$headers" + FAULT="it answered with no location" + return 1 +} + +# THE FALLBACK, AND ONLY WHERE `gh` IS INSTALLED: `gh api` needs a login even +# for a public repository, which is why it is not the first way. +latest_from_gh() { + local status=0 + FOUND="" + command -v gh >/dev/null 2>&1 || { + FAULT="gh is not installed" + return 1 + } + FOUND="$(gh api "repos/$REPO/releases/latest" --jq .tag_name 2>/dev/null)" || status=$? + if [ "$status" -ne 0 ] || [ -z "$FOUND" ]; then + FAULT="gh failed, exit $status" + return 1 + fi +} + +# THE COMMAND, AND NOT A PAGE THAT SAYS 200. A captive portal, a proxy's +# error page or a renamed file is refused here, before anything runs it: +# the launcher's first line, and the `INSTALLABLES=(` line that +# `tests/test_repo_hygiene.py` holds it to carrying. +looks_like_the_launcher() { + local line first=1 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$first" -eq 1 ]; then + [ "$line" = "#!/usr/bin/env bash" ] || return 1 + first=0 + fi + case "$line" in "INSTALLABLES=("*) return 0 ;; esac + done <"$1" + return 1 +} + +# THE ONE COMMAND THAT INSTALLS IT HERE, for a machine with no pipx. POSIX +# shell, no sudo: Homebrew's where `brew` is, and otherwise pip, under +# whichever Python name this machine has, at the version CI runs. +filter_repo_command() { + local candidate + if command -v brew >/dev/null 2>&1; then + printf '%s' "brew install git-filter-repo" + return 0 + fi + for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s' "$candidate -m pip install --user $FILTER_REPO_PIN" + return 0 + fi + done + printf '%s' "python3 -m pip install --user $FILTER_REPO_PIN" +} + +filter_repo_step() { + local found status=0 + if found="$(command -v git-filter-repo 2>/dev/null)" && [ -n "$found" ]; then + say "git-filter-repo: already installed ($found)" + return 0 + fi + if command -v git >/dev/null 2>&1 && + git filter-repo --version >/dev/null 2>&1; then + say "git-filter-repo: already installed (git filter-repo --version answers)" + return 0 + fi + if command -v pipx >/dev/null 2>&1; then + say "git-filter-repo: not installed; installing it with pipx, because adopt-project.py needs it: pipx install $FILTER_REPO_PIN" + pipx install "$FILTER_REPO_PIN" || status=$? + if [ "$status" -ne 0 ]; then + say "git-filter-repo: pipx could not install it (exit $status). Before adopting a repository, install it: pipx install $FILTER_REPO_PIN" + elif command -v git-filter-repo >/dev/null 2>&1; then + say "git-filter-repo: installed with pipx" + else + say "git-filter-repo: installed with pipx, into a directory that is not on PATH yet: run \`pipx ensurepath\` and open a new shell" + fi + return 0 + fi + say "git-filter-repo: not installed, and there is no pipx to install it with. Only adopt-project.py needs it; before adopting a repository, run:" + say " $(filter_repo_command)" +} + +main() { + local version="" asked launcher_url bin_dir target shown status=0 pinned=1 + + # NOTHING A CHILD READS IS THIS SCRIPT. Piped, stdin is the pipe the + # script came down; a terminal stays a terminal, which is where the + # preflight asks before it offers anything. + [ -t 0 ] || exec /dev/null 2>&1 || + die "curl is not installed, and this installer fetches with it. Install curl, or install by hand with: gh api repos/$REPO/contents/openRepoShape -H 'Accept: application/vnd.github.raw' | bash -s -- --install" + + # 1. THE RELEASE. + if [ -n "$version" ]; then + checked_version "$version" "the version" + say "release: $version, as named" + else + asked="https://github.com/$REPO/releases/latest" + if latest_from_redirect "$asked"; then + version="$FOUND" + else + local redirect_fault="$FAULT" + latest_from_gh || die "could not find the newest release of $REPO, so nothing was installed. Both ways failed: + curl -fsSI $asked ($redirect_fault) + gh api repos/$REPO/releases/latest ($FAULT) + Name a release instead, as bash install.sh v1.0.1 or as: + curl -fsSL https://github.com/$REPO/releases/latest/download/install.sh | bash -s -- v1.0.1" + version="$FOUND" + asked="gh api repos/$REPO/releases/latest" + fi + checked_version "$version" "the newest release's tag" + say "release: $version, the newest release of $REPO ($asked)" + fi + + # 2. THAT RELEASE'S COMMAND, FETCHED, CHECKED AND INSTALLED BY ITSELF. + WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/openRepoShape-install.XXXXXXXX")" || + die "could not make a temporary directory under ${TMPDIR:-/tmp}. Set \$TMPDIR to a directory you can write to." + trap 'rm -rf -- "$WORKDIR"' EXIT + launcher_url="https://raw.githubusercontent.com/$REPO/$version/openRepoShape" + curl -fsSL "$launcher_url" -o "$WORKDIR/openRepoShape" || status=$? + [ "$status" -eq 0 ] && [ -s "$WORKDIR/openRepoShape" ] || + die "could not fetch $launcher_url (curl exit $status), so nothing was installed. Is $version a release of $REPO? https://github.com/$REPO/releases" + looks_like_the_launcher "$WORKDIR/openRepoShape" || + die "$launcher_url is not the openRepoShape command (its first line is not #!/usr/bin/env bash, or it has no INSTALLABLES line), so nothing was installed." + say "fetch: $launcher_url" + + bin_dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" + target="$bin_dir/openRepoShape" + say "install: OPENREPOSHAPE_REF=$version openRepoShape --install" + OPENREPOSHAPE_REF="$version" "${BASH:-bash}" "$WORKDIR/openRepoShape" --install || + die "the $version openRepoShape's --install refused (exit $?); its own refusal above says why." + + # THE PIN, CHECKED BY ASKING THE INSTALLED COMMAND, with the variable + # unset: a release older than v1.0.1 installs without recording a ref and + # would follow main, which is the drift this installer exists to end. + shown="$(unset OPENREPOSHAPE_REF; "${BASH:-bash}" "$target" --version 2>/dev/null)" || shown="" + case "$shown" in + *"@ $version)") ;; + *) + pinned=0 + say "install: $target does not stay on $version: it says '${shown:-nothing}' to --version. A release older than v1.0.1 cannot record the ref it was installed at; export OPENREPOSHAPE_REF=$version to keep it there." + ;; + esac + if [ -n "${OPENREPOSHAPE_REF:-}" ] && [ "$OPENREPOSHAPE_REF" != "$version" ]; then + say "install: \$OPENREPOSHAPE_REF is set in this shell, and while it is, every run uses it instead of $version. Unset it to run $version." + fi + + # 3. GIT-FILTER-REPO. + filter_repo_step + + # 4. THE PREFLIGHT, LAST, THROUGH THE INSTALLED COMMAND AND ITS RECORD. + say "preflight: $target --preflight" + status=0 + ( + unset OPENREPOSHAPE_REF + "${BASH:-bash}" "$target" --preflight + ) || status=$? + + if [ "$status" -ne 0 ]; then + say "done: openRepoShape $version is installed at $target, but the preflight did not pass (exit $status); it names what is missing above." + exit 1 + fi + if [ "$pinned" -eq 0 ]; then + say "done: openRepoShape $version is installed at $target and this machine is ready, but the command does not stay on $version (see above)." + exit 1 + fi + say "done: openRepoShape $version is installed at $target, and this machine is ready. Run the same line again to upgrade to the newest release." + exit 0 +} + +# `${1+"$@"}`, setup.sh's own spelling: no arguments is the one-line run, and +# that has to survive `set -u` under every bash this has to run on. +main ${1+"$@"} diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py new file mode 100644 index 00000000..7ced9afc --- /dev/null +++ b/tests/test_install_sh.py @@ -0,0 +1,596 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`install.sh`: openRepoShape in one line, pinned to a release (#564). + +OFFLINE, ON A `$PATH` THE TEST OWNS ENTIRELY. Each run's `$PATH` holds the +real programs the installer and the command it installs need (`bash`, +`mktemp`, `cp` and the rest of `INSTALL_NEEDS`), and FAKES for everything +that would reach the network or install something: `curl`, `gh`, `pipx`, +and, where a test asks for them, `git-filter-repo`, `git` and `brew`. +Restricted rather than prepended, because the machine running this may have +git-filter-repo and pipx installed for real, and which of the three +git-filter-repo branches runs must be the test's choice, not the machine's. + +EVERY FAKE IS ONE PROGRAM. A two-line shell launcher per name hands its +arguments to `FAKE` below, which appends the call to `calls.log` and answers +from `net.json`: the redirect github.com answers `releases/latest` with, the +files raw.githubusercontent.com serves, the tag `gh api` reports, and where +`pipx` puts what it installs. The `setup.sh` it serves is another launcher +into the same program, so the preflight the installer ends with is LOGGED, +with the arguments the installed command handed it: `--shape-ref ` +is where the pin shows. + +THE COMMAND SERVED IS THIS CHECKOUT'S `openRepoShape`, so the install the +installer runs is the real one. Every run sets `GH_TOKEN=bogus` and an empty +`GH_CONFIG_DIR`, and its `TMPDIR` is a directory each test checks is empty +again afterwards. + +WINDOWS_SKIP, for the reason `tests/test_openreposhape_command.py` gives: the +installer and the command are bash, run where `bash` is a bash, and on +Windows the way in is `setup-project.py`. +""" + +from __future__ import annotations + +import json +import re +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP +from test_openreposhape_command import COMMAND, INSTALL_NEEDS, command_env + +INSTALLER = REPO / "install.sh" + +#: Bash by ABSOLUTE path: `subprocess` resolves a bare argv[0] against the +#: child's `$PATH`, and the one each run builds holds only what it chose. +BASH = shutil.which("bash") + +pytestmark = [pytest.mark.skipif(BASH is None, reason="install.sh is bash"), + WINDOWS_SKIP] + +SLUG = "opensoft/openRepoShape" +RELEASE = "v9.9.9" +FILTER_REPO = "git-filter-repo==2.47.0" + + +def latest(slug: str = SLUG) -> str: + return f"https://github.com/{slug}/releases/latest" + + +def tag_page(tag: str, slug: str = SLUG) -> str: + return f"https://github.com/{slug}/releases/tag/{tag}" + + +def raw(name: str, ref: str = RELEASE, slug: str = SLUG) -> str: + return f"https://raw.githubusercontent.com/{slug}/{ref}/{name}" + + +#: THE ONE PROGRAM BEHIND EVERY FAKE. `curl -fsSI` answers a configured +#: redirect with the headers github.com sends, CRLF and all, and `curl +#: -fsSL` a configured file, to `-o` or to stdout; anything else is curl's +#: own exit 22. `gh` reports a release only when one is configured. `pipx` +#: places a `git-filter-repo` launcher where it is told to, or fails. Every +#: call is logged first, so what was NOT asked is as plain as what was. +FAKE = r''' +import json +import shlex +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +NET = json.loads((HERE / "net.json").read_text(encoding="utf-8")) +NAME, ARGS = sys.argv[1], sys.argv[2:] +with open(HERE / "calls.log", "a", encoding="utf-8") as log: + log.write(" ".join([NAME] + ARGS) + "\n") + + +def refuse(message): + sys.stderr.write(message + "\n") + return 22 if NAME == "curl" else 1 + + +def launcher(directory, name): + path = Path(directory) / name + path.write_text("#!/bin/sh\nexec %s %s %s \"$@\"\n" % ( + shlex.quote(sys.executable), shlex.quote(str(Path(__file__))), name), + encoding="utf-8") + path.chmod(0o755) + + +def curl(): + url = next((arg for arg in ARGS if arg.startswith("https://")), "") + if "-fsSI" in ARGS: + location = NET["redirects"].get(url) + if location is None: + return refuse("curl: (22) The requested URL returned error: 404") + sys.stdout.write("HTTP/2 302 \r\nlocation: %s\r\n" + "content-length: 0\r\n\r\n" % location) + return 0 + served = NET["files"].get(url) + if served is None: + return refuse("curl: (22) The requested URL returned error: 404") + data = Path(served).read_bytes() + if "-o" in ARGS: + Path(ARGS[ARGS.index("-o") + 1]).write_bytes(data) + else: + sys.stdout.buffer.write(data) + return 0 + + +def gh(): + if ARGS[:1] == ["api"] and ARGS[1].endswith("/releases/latest") \ + and NET.get("gh_latest"): + print(NET["gh_latest"]) + return 0 + return refuse("fake gh: no answer for: " + " ".join(ARGS)) + + +def pipx(): + if NET.get("pipx_installs_into") is None: + return refuse("fake pipx: could not install " + " ".join(ARGS)) + launcher(NET["pipx_installs_into"], "git-filter-repo") + return 0 + + +def git(): + if ARGS == ["filter-repo", "--version"] and NET.get("git_filter_repo"): + print("a40bce548d2c") + return 0 + return refuse("fake git: no answer for: " + " ".join(ARGS)) + + +def setup_sh(): + print("fake preflight: " + " ".join(ARGS)) + return NET.get("preflight_exit", 0) + + +sys.exit({"curl": curl, "gh": gh, "pipx": pipx, "git": git, + "setup.sh": setup_sh}.get(NAME, lambda: 0)()) +''' + +#: A RELEASE OLDER THAN v1.0.1, as far as the installer can tell: the +#: launcher's first line and its `INSTALLABLES` line, an `--install` that +#: copies itself and records nothing, and a `--version` that therefore says +#: `main`. It is all the installer asks of a command. +OLD_LAUNCHER = """#!/usr/bin/env bash +INSTALLABLES=(openRepoShape) +case "$1" in +--install) mkdir -p "$OPENREPOSHAPE_BIN_DIR" && cp "$0" "$OPENREPOSHAPE_BIN_DIR/openRepoShape" ;; +--version) printf 'openRepoShape (%s @ %s)\\n' "$OPENREPOSHAPE_REPO" "${OPENREPOSHAPE_REF:-main}" ;; +esac +""" + + +class Net: + """The fakes, what they answer, the `$PATH` that holds them, and a run. + + `fakes` names the programs on that `$PATH` that answer through `FAKE`; + `serve` adds a release's two files; `config` is written to `net.json` + at every `run`, so a test changes an answer between two runs by + changing it here. + """ + + def __init__(self, tmp_path: Path, fakes=("curl", "gh", "pipx")): + self.tmp_path = tmp_path + self.root = tmp_path / "net" + self.root.mkdir() + (self.root / "fake.py").write_text(FAKE, encoding="utf-8") + self.path = tmp_path / "path" + self.path.mkdir() + for name in (*INSTALL_NEEDS, "bash"): + real = shutil.which(name) + assert real, f"this machine has no {name}, which the install needs" + (self.path / name).symlink_to(real) + for name in fakes: + self.fake(name) + self.temporary = tmp_path / "tmp" + self.temporary.mkdir() + self.bin = tmp_path / ".local" / "bin" + self.config = {"redirects": {latest(): tag_page(RELEASE)}, + "files": {}, "pipx_installs_into": str(self.path)} + self.serve(RELEASE) + + def fake(self, name: str, directory: Path | None = None) -> Path: + """A launcher for `name` into `FAKE`, on this `$PATH` by default.""" + path = (directory or self.path) / name + path.write_text( + f"#!/bin/sh\nexec {shlex.quote(sys.executable)} " + f"{shlex.quote(str(self.root / 'fake.py'))} {name} \"$@\"\n", + encoding="utf-8") + path.chmod(0o755) + return path + + def serve(self, ref: str, slug: str = SLUG, + launcher: Path = COMMAND) -> None: + """`openRepoShape` and `setup.sh` at `ref`, as raw URLs answer.""" + served = self.root / "served" / ref + served.mkdir(parents=True, exist_ok=True) + self.config["files"][raw("openRepoShape", ref, slug)] = str(launcher) + self.config["files"][raw("setup.sh", ref, slug)] = str( + self.fake("setup.sh", served)) + + def run(self, *args: str, piped: bool = False, + env: dict | None = None) -> subprocess.CompletedProcess: + """`bash install.sh `, or `… | bash -s -- ` when piped.""" + (self.root / "net.json").write_text(json.dumps(self.config), + encoding="utf-8") + gh_config = self.tmp_path / "gh-config" + gh_config.mkdir(exist_ok=True) + argv = [BASH, "-s", "--", *args] if piped \ + else [BASH, str(INSTALLER), *args] + return subprocess.run( + argv, capture_output=True, text=True, check=False, + input=INSTALLER.read_text(encoding="utf-8") if piped else "", + cwd=str(self.tmp_path), start_new_session=True, + env=command_env(home=self.tmp_path, setup_sh=None, env={ + "PATH": str(self.path), "GH_TOKEN": "bogus", + "GH_CONFIG_DIR": str(gh_config), + "TMPDIR": str(self.temporary), **(env or {})})) + + def calls(self) -> list: + log = self.root / "calls.log" + return log.read_text(encoding="utf-8").splitlines() \ + if log.exists() else [] + + def installed(self, result, ref: str = RELEASE) -> None: + """The command is placed, byte for byte, with `ref` recorded beside + it, and the run left nothing in `$TMPDIR`.""" + target = self.bin / "openRepoShape" + assert target.read_bytes() == COMMAND.read_bytes(), ( + result.stdout + result.stderr) + assert (self.bin / "openRepoShape.ref").read_text( + encoding="utf-8") == f"{ref}\n" + assert list(self.temporary.iterdir()) == [] + + def refused(self, result) -> None: + """Exit 2, nothing placed, no preflight run, nothing left behind.""" + assert result.returncode == 2, result.stdout + result.stderr + assert not self.bin.exists(), "a refused install placed something" + assert not any(call.startswith("setup.sh") for call in self.calls()) + assert list(self.temporary.iterdir()) == [] + + +@pytest.fixture +def net(tmp_path): + return Net(tmp_path) + + +def preflight_call(ref: str = RELEASE) -> str: + """How the preflight reaches setup.sh through a command pinned to `ref`.""" + return f"setup.sh --shape-ref {ref} --preflight" + + +# --- the one line ------------------------------------------------------------ + +def test_the_one_line_installs_the_newest_release_pinned_to_it(net): + """`curl … | bash`: the newest release, from the redirect alone. + + One line per step, in order, and the calls behind them in the same + order: the redirect, the release's command, then the preflight, LAST, + through the installed command, whose `setup.sh` is fetched at the + release and handed `--shape-ref` for it. `gh` is never asked for a + release when the redirect named one. + """ + result = net.run(piped=True) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + target = net.bin / "openRepoShape" + steps = [ + f"release: {RELEASE}, the newest release of {SLUG} ({latest()})", + f"fetch: {raw('openRepoShape')}", + f"install: OPENREPOSHAPE_REF={RELEASE} openRepoShape --install", + f"openRepoShape: pinned to {RELEASE}, recorded in {target}.ref", + "git-filter-repo: ", + f"preflight: {target} --preflight", + f"done: openRepoShape {RELEASE} is installed at {target}, and this " + "machine is ready. Run the same line again to upgrade to the newest " + "release.", + ] + lines = result.stdout.splitlines() + at = [next(i for i, line in enumerate(lines) if line.startswith(step)) + for step in steps] + assert at == sorted(at), result.stdout + calls = net.calls() + assert calls[0] == f"curl -fsSI {latest()}" + assert calls[1].startswith(f"curl -fsSL {raw('openRepoShape')} -o ") + assert f"curl -fsSL {raw('setup.sh')}" in calls + assert calls[-1] == preflight_call(), calls + assert not any("releases/latest" in call for call in calls[1:]), calls + + +@pytest.mark.parametrize("piped", [False, True], ids=["file", "piped"]) +def test_a_named_release_is_installed_and_no_newest_is_asked_for(net, piped): + """`bash install.sh v9.9.8` and `… | bash -s -- v9.9.8`: that release, + and no question about which one is newest.""" + net.serve("v9.9.8") + result = net.run("v9.9.8", piped=piped) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result, "v9.9.8") + assert "release: v9.9.8, as named" in result.stdout.splitlines() + assert not any("-fsSI" in call for call in net.calls()), net.calls() + assert net.calls()[-1] == preflight_call("v9.9.8") + + +def test_gh_answers_when_the_redirect_names_no_release(net): + """A redirect to `/releases` names no tag (what github.com answers before + a repository's first release); `gh api` is asked next, where installed.""" + net.config["redirects"][latest()] = f"https://github.com/{SLUG}/releases" + net.config["gh_latest"] = RELEASE + result = net.run() + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"release: {RELEASE}, the newest release of {SLUG} (gh api " \ + f"repos/{SLUG}/releases/latest)" in result.stdout + + +@pytest.mark.parametrize("location, fakes, redirect_fault, gh_fault", [ + pytest.param(f"https://github.com/{SLUG}/releases", ("curl",), + f"it redirects to https://github.com/{SLUG}/releases, which " + "names no release", "gh is not installed", id="no-release"), + pytest.param(None, ("curl", "gh"), "curl failed, exit 22", + "gh failed, exit 1", id="both-failed"), +]) +def test_no_release_found_names_each_way_that_failed( + tmp_path, location, fakes, redirect_fault, gh_fault): + """Each way names its URL or command and why it gave no answer, and the + way out is to name a release; nothing is fetched or installed.""" + net = Net(tmp_path, fakes) + if location is None: + del net.config["redirects"][latest()] + else: + net.config["redirects"][latest()] = location + result = net.run() + net.refused(result) + assert f"could not find the newest release of {SLUG}" in result.stderr + assert f"curl -fsSI {latest()} ({redirect_fault})" in result.stderr + assert f"gh api repos/{SLUG}/releases/latest ({gh_fault})" \ + in result.stderr + assert "bash install.sh v1.0.1" in result.stderr + assert not any("-fsSL" in call for call in net.calls()) + + +@pytest.mark.parametrize("args, said", [ + pytest.param(("v1..v2",), "the version is 'v1..v2'", id="a-range"), + pytest.param(("x.lock",), "the version is 'x.lock'", id="a-lock-file"), + pytest.param(("/etc",), "the version is '/etc'", id="a-path"), + pytest.param(("v1\nforged",), "the version carries a control character", + id="two-lines"), + pytest.param(("a" * 256,), "the version is 256 characters long", + id="over-long"), + pytest.param(("-x",), "unknown option '-x'", id="an-option"), + pytest.param(("-x\nforged",), "an option carries a control character", + id="an-option-of-two-lines"), + pytest.param(("v1.0.1", "v1.0.2"), "two versions given", id="two"), +]) +def test_a_version_that_is_not_a_ref_is_refused_before_any_request( + net, args, said): + """Checked the way the launcher checks a ref, before any URL is built + from it; a control character and the over-long value never echoed.""" + result = net.run(*args) + net.refused(result) + assert said in result.stderr + assert net.calls() == [] + assert "forged" not in result.stderr and "a" * 256 not in result.stderr + + +def test_a_failed_fetch_names_its_url_and_installs_nothing(net): + del net.config["files"][raw("openRepoShape")] + result = net.run() + net.refused(result) + assert f"could not fetch {raw('openRepoShape')} (curl exit 22), so " \ + "nothing was installed" in result.stderr + + +def test_a_page_that_is_not_the_command_is_refused(net): + """A proxy's or a portal's page can arrive with a 200; it is refused + before anything runs it.""" + page = net.root / "page.html" + page.write_text("Sign in\n", + encoding="utf-8") + net.config["files"][raw("openRepoShape")] = str(page) + result = net.run() + net.refused(result) + assert f"{raw('openRepoShape')} is not the openRepoShape command" \ + in result.stderr + + +# --- git-filter-repo --------------------------------------------------------- + +def present_on_path(net): + net.fake("git-filter-repo") + return f"git-filter-repo: already installed ({net.path}/git-filter-repo)" + + +def answering_git(net): + net.fake("git") + net.config["git_filter_repo"] = True + return "git-filter-repo: already installed (git filter-repo --version " \ + "answers)" + + +def pipx_onto_path(net): + return "git-filter-repo: installed with pipx" + + +def pipx_off_path(net): + elsewhere = net.tmp_path / "pipx-bin" + elsewhere.mkdir() + net.config["pipx_installs_into"] = str(elsewhere) + return "git-filter-repo: installed with pipx, into a directory that is " \ + "not on PATH yet: run `pipx ensurepath` and open a new shell" + + +def pipx_failing(net): + net.config["pipx_installs_into"] = None + return f"git-filter-repo: pipx could not install it (exit 1). Before " \ + f"adopting a repository, install it: pipx install {FILTER_REPO}" + + +def no_pipx(net): + (net.path / "pipx").unlink() + return f" python3 -m pip install --user {FILTER_REPO}" + + +def no_pipx_but_brew(net): + no_pipx(net) + net.fake("brew") + return " brew install git-filter-repo" + + +@pytest.mark.parametrize("arrange", [ + present_on_path, answering_git, pipx_onto_path, pipx_off_path, + pipx_failing, no_pipx, no_pipx_but_brew]) +def test_git_filter_repo_is_said_installed_or_printed(net, arrange): + """The three branches, and the ways each can end. + + PRESENT, on `$PATH` or as `git filter-repo`: said, and pipx is never + run. NOT PRESENT AND PIPX IS THERE: said first, then `pipx install` at + CI's version, then what came of it. NEITHER: the one command for this + machine, printed and never run. None changes the exit status, and the + step sits between the install and the preflight. + """ + expected = arrange(net) + result = net.run() + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + lines = result.stdout.splitlines() + assert expected in lines, result.stdout + ran_pipx = [call for call in net.calls() if call.startswith("pipx")] + if arrange in (pipx_onto_path, pipx_off_path, pipx_failing): + assert ran_pipx == [f"pipx install {FILTER_REPO}"], net.calls() + assert "git-filter-repo: not installed; installing it with pipx, "\ + "because adopt-project.py needs it: pipx install " \ + f"{FILTER_REPO}" in lines + assert net.calls().index(ran_pipx[0]) < \ + net.calls().index(preflight_call()) + else: + assert ran_pipx == [], net.calls() + if arrange in (no_pipx, no_pipx_but_brew): + assert lines[lines.index(expected) - 1] == ( + "git-filter-repo: not installed, and there is no pipx to install " + "it with. Only adopt-project.py needs it; before adopting a " + "repository, run:") + assert lines.index(expected) < lines.index( + f"preflight: {net.bin}/openRepoShape --preflight") + + +# --- the preflight, the exit status, and running it again -------------------- + +def test_a_preflight_that_does_not_pass_exits_one_with_the_command_placed( + net): + net.config["preflight_exit"] = 1 + result = net.run() + assert result.returncode == 1, result.stdout + result.stderr + net.installed(result) + assert f"done: openRepoShape {RELEASE} is installed at " \ + f"{net.bin}/openRepoShape, but the preflight did not pass (exit 1); " \ + "it names what is missing above." in result.stdout + + +def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): + """`$OPENREPOSHAPE_REF` exported in the person's shell overrides the pin + on every run while it stays set, so the installer says so; the release + is still what is recorded, and the preflight still runs through it.""" + result = net.run(env={"OPENREPOSHAPE_REF": "v0.0.1"}) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"install: $OPENREPOSHAPE_REF is set in this shell, and while it " \ + f"is, every run uses it instead of {RELEASE}. Unset it to run " \ + f"{RELEASE}." in result.stdout + assert net.calls()[-1] == preflight_call() + + +def test_the_same_line_again_changes_nothing_and_then_upgrades(net): + """Idempotent while the newest release is the same one; the next + release, once it is the newest, replaces the record and says which it + replaced.""" + assert net.run().returncode == 0 + again = net.run() + assert again.returncode == 0, again.stdout + again.stderr + assert f"openRepoShape: already installed at {net.bin}/openRepoShape " \ + "(unchanged)" in again.stdout + assert f"openRepoShape: already pinned to {RELEASE} in " \ + f"{net.bin}/openRepoShape.ref (unchanged)" in again.stdout + + net.config["redirects"][latest()] = tag_page("v10.0.0") + net.serve("v10.0.0") + upgrade = net.run() + assert upgrade.returncode == 0, upgrade.stdout + upgrade.stderr + net.installed(upgrade, "v10.0.0") + assert f"openRepoShape: pinned to v10.0.0 (was {RELEASE}), recorded in " \ + f"{net.bin}/openRepoShape.ref" in upgrade.stdout + + +def test_a_release_whose_command_records_no_ref_exits_one(net): + """A release before v1.0.1 installs a command that follows main. The + installer asks the installed command, says so, and exits 1.""" + old = net.root / "old-openRepoShape" + old.write_text(OLD_LAUNCHER, encoding="utf-8") + net.serve(RELEASE, launcher=old) + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(net.bin), + "OPENREPOSHAPE_REPO": SLUG}) + assert result.returncode == 1, result.stdout + result.stderr + assert f"install: {net.bin}/openRepoShape does not stay on {RELEASE}: " \ + f"it says 'openRepoShape ({SLUG} @ main)' to --version." \ + in result.stdout + assert f"export OPENREPOSHAPE_REF={RELEASE}" in result.stdout + assert result.stdout.splitlines()[-1].endswith( + f"but the command does not stay on {RELEASE} (see above).") + + +# --- the repository it installs from ---------------------------------------- + +def test_openreposhape_repo_names_where_everything_comes_from(net): + """A fork named once is where the release, the command and the + preflight's setup.sh all come from, and the installed command says so.""" + fork = "acme/openRepoShape" + net.config["redirects"] = {latest(fork): tag_page(RELEASE, fork)} + net.config["files"] = {} + net.serve(RELEASE, fork) + result = net.run(env={"OPENREPOSHAPE_REPO": fork}) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert all(SLUG not in call for call in net.calls()), net.calls() + assert f"curl -fsSL {raw('setup.sh', RELEASE, fork)}" in net.calls() + + +@pytest.mark.parametrize("value", [ + "https://github.com/acme/openRepoShape.git", "acme", "acme/x/y", + "/srv/openRepoShape"]) +def test_a_repository_that_is_not_an_owner_and_name_is_refused(net, value): + result = net.run(env={"OPENREPOSHAPE_REPO": value}) + net.refused(result) + assert f"$OPENREPOSHAPE_REPO is '{value}', which is not an owner/name " \ + "on github.com" in result.stderr + assert net.calls() == [] + + +# --- what the file says about itself ------------------------------------------ + +def test_help_prints_the_one_line_and_exits_zero(tmp_path): + result = subprocess.run( + [BASH, str(INSTALLER), "--help"], capture_output=True, text=True, + check=False, input="", + env=command_env(home=tmp_path, setup_sh=None, + env={"GH_TOKEN": "bogus", + "GH_CONFIG_DIR": str(tmp_path)})) + assert result.returncode == 0, result.stderr + assert "curl -fsSL https://github.com/opensoft/openRepoShape/releases/" \ + "latest/download/install.sh | bash" in result.stdout + + +def test_it_installs_the_git_filter_repo_ci_runs(): + """What the installer puts on a machine is what the adoption suites ran + against: the pin it names is every pin `.github/workflows/tests.yml` + installs.""" + [pinned] = re.findall(r'^FILTER_REPO_PIN="(git-filter-repo==[^"]+)"$', + INSTALLER.read_text(encoding="utf-8"), re.M) + ci = re.findall(r"git-filter-repo==[0-9.]+", ( + REPO / ".github" / "workflows" / "tests.yml").read_text( + encoding="utf-8")) + assert ci and set(ci) == {pinned}, (pinned, ci) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 3761eb76..ff065cf7 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -66,12 +66,13 @@ "shape_advisory", "conftest", "bootstrap"} #: The bash scripts a person runs BEFORE they have a checkout, or with no -#: checkout in sight at all: the front door itself and the command that -#: fetches it. `park` and `resume` were here from #82 until the carve (#92) -#: and are opensoft/openRepoTools' now, held to these same rules by that -#: repository's own copy of this test. Every one is a shipped executable and -#: is held to the same shebang, mode bit and `set -euo pipefail` rule. -SHIPPED_BASH = ["setup.sh", "openRepoShape"] +#: checkout in sight at all: the front door itself, the command that +#: fetches it, and the one-line installer that places that command pinned to +#: a release (#564). `park` and `resume` were here from #82 until the carve +#: (#92) and are opensoft/openRepoTools' now, held to these same rules by +#: that repository's own copy of this test. Every one is a shipped executable +#: and is held to the same shebang, mode bit and `set -euo pipefail` rule. +SHIPPED_BASH = ["setup.sh", "openRepoShape", "install.sh"] #: The entry point a person runs BEFORE they have a checkout on a machine with #: no bash: the same front door, on an interpreter alone. Held to the same @@ -1183,8 +1184,25 @@ def test_readme_is_short_enough_to_be_read(): # shim's answer there changed from a clone of the upstream to a few # lines of its own, and a README that still promised the flag list # would send a reader to a command that no longer prints it. - assert len(lines) <= 1422, ( - f"README.md is {len(lines)} lines; the cap is 1422") + # 2026-10-14: 1422 -> 1450 — the one-line install, v1.0.1 (#564). Brett + # Heap: "we need it to be more simple to install ... now and after, we + # need a simple install process". Step 3 of the quick start now LEADS + # with `curl -fsSL …/releases/latest/download/install.sh | bash`, and + # the worked example's "Install the command" with the same line: + # eleven lines for the two blocks and their lead-ins. Nine say what + # that line does that the old two did not, because each is a thing a + # reader would otherwise meet unannounced on their own machine: it + # reads the newest release from a redirect with no `gh`, it installs + # git-filter-repo with pipx or prints the command, it ends in the + # preflight, it never runs `sudo`, how to name a release, and its three + # exit statuses. Five say what PINNED means — the record beside the + # command, what reads it, and the variable that overrides it — because + # a command that quietly stops following main is a surprise to anyone + # who expected it to. Two re-introduce the existing lines as the + # by-hand way, at main, so neither is lost, and one is the Layout row + # for `install.sh`. + assert len(lines) <= 1450, ( + f"README.md is {len(lines)} lines; the cap is 1450") #: #476: what each document that carries the first rule says an assistant does @@ -1247,7 +1265,7 @@ def test_shipped_bash_is_executable_and_fails_loudly(name): @WINDOWS_SKIP @pytest.mark.parametrize("name", SHIPPED_BASH) def test_shipped_bash_parses_under_bash(name): - """`bash -n` on the two shipped bash scripts, everywhere there is a bash. + """`bash -n` on the shipped bash scripts, everywhere there is a bash. SKIPPED ON WINDOWS, AND `shutil.which` IS NOT ENOUGH TO SEE WHY. The `bash` a stock Windows install puts on PATH is @@ -1341,6 +1359,27 @@ def test_the_openrepotools_install_line_is_the_same_everywhere(name): f" {OPENREPOTOOLS_INSTALL}") +#: THE ONE LINE THAT INSTALLS THIS STANDARD (#564), and every file that +#: carries it: the installer it fetches, the command it installs (in its +#: header), the README and its designed reading, and the release notes that +#: introduced it. Pasted by people who cannot check it, like the two lines +#: above, so the copies are held byte for byte. `releases/latest/download/` +#: is what makes it the NEWEST release's `install.sh` every time. +ONE_LINE_INSTALL = ( + "curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/" + "download/install.sh | bash") +ONE_LINE_INSTALL_IN = ["install.sh", "openRepoShape", "README.md", + "docs/handbook.html", "docs/release-notes/v1.0.1.md"] + + +@pytest.mark.parametrize("name", ONE_LINE_INSTALL_IN) +def test_the_one_line_install_is_the_same_everywhere(name): + text = (REPO / name).read_text(encoding="utf-8") + assert ONE_LINE_INSTALL in text, ( + f"{name} must carry the one-line install byte for byte:\n" + f" {ONE_LINE_INSTALL}") + + def test_nothing_here_installs_park_or_resume(): """The carve, asserted rather than remembered (#92). From 319b005da6d315451a8ef4e31e6f28cfed699186 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:21:46 +0000 Subject: [PATCH 03/14] openRepoShape and install.sh, the fix round on #577: `--install` records a ref only with `OPENREPOSHAPE_RECORD_REF=1`, which install.sh sets, so a script that exports `$OPENREPOSHAPE_REF` around it gets v1.0.0's install; every curl that runs is HTTPS only, redirects included, at TLS 1.2 or newer; a link to the command reads its record; `--help` answers past a broken record; and install.sh says which failure it met (#564) The review on #577 (RV577, FIX-ROUND) and SonarCloud shell:S6506. openRepoShape: - The ref is recorded only when asked, or when it came from the command's own record (a re-install of itself stays pinned). workBenches exports `OPENREPOSHAPE_REF=pinned-by-workBenches-no-fetch` around `--install`; that no longer becomes a record every later run would fail on. Any other install records nothing and removes a record already there. - The raw-URL fallback curl carries `--proto '=https' --proto-redir '=https' --tlsv1.2`, written on the line. - The record is found beside the FILE, symlinks followed with a portable `readlink` loop (no `-f`, at most 40 links). - A broken record is kept as a fault and refused after the arguments are read, so `--help` still answers; `--version` and every run refuse. A record that is a directory is refused in the usual form, by a run and by an install, before anything is placed. install.sh: - Both curls HTTPS only, redirects included, TLS 1.2 or newer. - Sets `OPENREPOSHAPE_RECORD_REF=1` for the install. - A fetch the server refused (curl exit 22) asks whether the release exists; no answer at all points at the network instead. A preflight that exits 1 names what is missing; any other status says it did not run to the end. - With no pipx, `apt-get` machines are told `sudo apt-get install -y pipx && pipx install git-filter-repo==2.47.0` (printed, never run), because Debian 12 and Ubuntu 23.04+ refuse `pip install --user`. - A command that did not record the tag gets its preflight run with the tag named, not main's setup.sh; the shell-profile notes say so. - The last line is `{ main ${1+"$@"}; }`: a download cut inside it runs nothing. install.sh itself never runs sudo; the wording now says the preflight's offers, some of them sudo, each wait for a typed yes. Tests: HTTPS_ONLY in the call asserts and a guard on every curl that runs in both scripts; the opt-in, links, a directory record and `--help` past a bad record; an upper-case and a relative `location`; a non-default OPENREPOSHAPE_BIN_DIR; the apt-get branch; the network failure; the preflight's two failures; three truncated downloads; S9073 composite asserts split. docs/cli.md regenerated for the `--help` change. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 19 +-- install.sh | 130 ++++++++++++------ openRepoShape | 198 ++++++++++++++++++++-------- tests/test_install_sh.py | 188 +++++++++++++++++++++----- tests/test_openreposhape_command.py | 80 ++++++++++- 5 files changed, 474 insertions(+), 141 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7e81e1af..9f2a8f82 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -155,14 +155,17 @@ opensoft/openRepoTools', and its own installer places them: $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one - -`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the -command it places: $OPENREPOSHAPE_REF, which the one-line installer -(install.sh) sets to the release it installs. The installed command then -stays on that ref: it fetches setup.sh at it, setup.sh clones the standard -at it (`--shape-ref`), and `--doctor` checks its clone out at it. -$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the -ref in force. Installed with no ref, it records none and follows main. + $OPENREPOSHAPE_RECORD_REF 1: --install records $OPENREPOSHAPE_REF (below) + +`--install` RECORDS A REF ONLY WHEN ASKED: with $OPENREPOSHAPE_RECORD_REF=1 +it writes $OPENREPOSHAPE_REF to `openRepoShape.ref` beside the command it +places, which is what the one-line installer (install.sh) does with the +release tag it installs. The installed command then stays on that ref: it +fetches setup.sh at it, setup.sh clones the standard at it (`--shape-ref`), +and `--doctor` checks its clone out at it. $OPENREPOSHAPE_REF set for one run +still wins, and `--version` prints the ref in force. A command re-installing +itself keeps its own record; any other install records nothing, removes a +record already there, and follows main. `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: diff --git a/install.sh b/install.sh index 8713a67a..453507bb 100755 --- a/install.sh +++ b/install.sh @@ -18,33 +18,36 @@ # when the redirect names no release and `gh` is installed. # 2. THAT RELEASE'S `openRepoShape`, fetched into a temporary directory, # checked to be the command, and installed by its own `--install` with -# `$OPENREPOSHAPE_REF` set to the release. `--install` records the ref -# beside the command it places, and the command then stays on it. +# `$OPENREPOSHAPE_REF` set to the release tag and +# `$OPENREPOSHAPE_RECORD_REF=1`, which asks it to record that tag beside +# the command it places; the command then stays on it. # 3. GIT-FILTER-REPO, which `adopt-project.py` needs and nothing else does: # said when it is there; installed with `pipx` when pipx is there and it # is not; otherwise the one command that installs it, printed. # 4. THE INSTALLED COMMAND'S `--preflight`, LAST, and its verdict. # -# IT NEVER RUNS sudo AND INSTALLS NOTHING ELSE. The command and, through pipx, -# git-filter-repo are the whole of what it installs. The preflight it ends -# with offers what is missing behind a typed `yes`, at the person's own -# terminal, exactly as `openRepoShape --preflight` always has. +# install.sh ITSELF NEVER RUNS sudo, AND INSTALLS NOTHING BUT THESE TWO: the +# command and, through pipx, git-filter-repo. The preflight it ends with +# offers what is missing, each behind a typed `yes` at the person's own +# terminal, exactly as `openRepoShape --preflight` always has, and some of +# those offers are `sudo` commands: that is the preflight's, asked first. # # EXIT STATUS: -# 0 installed, pinned to the release, and the preflight passed; +# 0 installed, pinned to the release tag, and the preflight passed; # 1 installed, but the preflight did not pass, or the installed command -# does not stay on the release (one older than v1.0.1 cannot record +# does not stay on the release tag (one older than v1.0.1 cannot record # it): the lines above say which; # 2 refused before anything was installed: a version that is not a ref, no # release found, a fetch that failed, or a file that is not the command. # git-filter-repo never changes it: only adopting needs it, and # `adopt-project.py` refuses with its own install command where it is missing. # -# EVERYTHING RUNS FROM `main`, CALLED ON THE LAST LINE. Under `curl … | bash` -# bash reads this script from the pipe as it goes, so a download cut off -# part-way would run whatever had arrived. Inside a function nothing runs -# until the last line has arrived, and by then the pipe holds nothing a child -# could read in its place. +# EVERYTHING RUNS FROM `main`, CALLED ON THE LAST LINE, INSIDE BRACES. Under +# `curl … | bash` bash reads this script from the pipe as it goes, so a +# download cut off part-way would run whatever had arrived. Inside a function +# nothing runs until the last line has arrived; the braces make that line one +# command that bash parses whole, so a download cut inside it runs nothing +# either; and by then the pipe holds nothing a child could read in its place. # # BASH 3.2, which is /bin/bash on every Mac: no `${x,,}`, no `declare -A`, # no `mapfile`; an empty array never meets `set -u`. @@ -77,13 +80,15 @@ install.sh: install openRepoShape, pinned to a release. bash install.sh [] With no it installs the newest release. It fetches that release's -`openRepoShape`, installs it into ~/.local/bin pinned to the release, installs -git-filter-repo with pipx when pipx is there and git-filter-repo is not (or -prints the command that installs it), and ends with `openRepoShape ---preflight`. It never runs sudo. Run the same line again to upgrade. +`openRepoShape`, installs it into ~/.local/bin pinned to the release tag, +installs git-filter-repo with pipx when pipx is there and git-filter-repo is +not (or prints the command that installs it), and ends with `openRepoShape +--preflight`. install.sh itself never runs sudo; the preflight it ends with +offers what is missing, each behind a typed `yes` at your terminal. Run the +same line again to upgrade. Exit 0: installed, pinned, and the preflight passed. 1: installed, but the -preflight did not pass, or the command does not stay on the release. 2: +preflight did not pass, or the command does not stay on the release tag. 2: refused, and nothing was installed. $OPENREPOSHAPE_REPO owner/name to install from (default opensoft/openRepoShape) @@ -131,7 +136,13 @@ checked_version() { # `location: …/releases/tag/`; a repository with no release yet answers # with `…/releases`, which names no tag and is no answer. The header name is # matched in any case (HTTP/2 sends it in lower case, HTTP/1.1 need not), -# without `${x,,}`, which bash 3.2 does not have. +# without `${x,,}`, which bash 3.2 does not have, and a relative location is +# read the same way as a whole URL. +# +# EVERY curl HERE IS HTTPS ONLY, REDIRECTS INCLUDED, AT TLS 1.2 OR NEWER +# (SonarCloud shell:S6506), with the flags written out on the curl line +# itself, never gathered into an array, so a reader and the analyser both +# see them there. `tests/test_install_sh.py` holds every curl line to it. latest_from_redirect() { local url="$1" headers line value status=0 FOUND="" @@ -139,7 +150,7 @@ latest_from_redirect() { FAULT="curl is not installed" return 1 } - headers="$(curl -fsSI "$url" 2>/dev/null)" || status=$? + headers="$(curl -fsSI --proto '=https' --proto-redir '=https' --tlsv1.2 "$url" 2>/dev/null)" || status=$? if [ "$status" -ne 0 ]; then FAULT="curl failed, exit $status" return 1 @@ -205,15 +216,22 @@ looks_like_the_launcher() { return 1 } -# THE ONE COMMAND THAT INSTALLS IT HERE, for a machine with no pipx. POSIX -# shell, no sudo: Homebrew's where `brew` is, and otherwise pip, under -# whichever Python name this machine has, at the version CI runs. +# THE ONE COMMAND THAT INSTALLS IT HERE, for a machine with no pipx, PRINTED +# AND NEVER RUN, in POSIX shell: Homebrew's where `brew` is; where `apt-get` +# is, pipx from apt and then git-filter-repo through it, because Debian 12 +# and Ubuntu 23.04 on refuse `pip install --user` into the system Python +# (externally-managed-environment); and otherwise pip, under whichever Python +# name this machine has. Always at the version CI runs, except brew's. filter_repo_command() { local candidate if command -v brew >/dev/null 2>&1; then printf '%s' "brew install git-filter-repo" return 0 fi + if command -v apt-get >/dev/null 2>&1; then + printf '%s' "sudo apt-get install -y pipx && pipx install $FILTER_REPO_PIN" + return 0 + fi for candidate in python3 python; do if command -v "$candidate" >/dev/null 2>&1; then printf '%s' "$candidate -m pip install --user $FILTER_REPO_PIN" @@ -305,17 +323,27 @@ main() { die "could not make a temporary directory under ${TMPDIR:-/tmp}. Set \$TMPDIR to a directory you can write to." trap 'rm -rf -- "$WORKDIR"' EXIT launcher_url="https://raw.githubusercontent.com/$REPO/$version/openRepoShape" - curl -fsSL "$launcher_url" -o "$WORKDIR/openRepoShape" || status=$? - [ "$status" -eq 0 ] && [ -s "$WORKDIR/openRepoShape" ] || - die "could not fetch $launcher_url (curl exit $status), so nothing was installed. Is $version a release of $REPO? https://github.com/$REPO/releases" + curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 "$launcher_url" -o "$WORKDIR/openRepoShape" || status=$? + # WHICH FAILURE IT WAS DECIDES WHAT TO SAY. curl's exit 22 is the server + # answering with an error, which for a raw URL means no such file at that + # ref; anything else is curl getting no answer at all (DNS, a proxy, TLS, + # a timeout), where asking whether the release exists would send the + # person looking in the wrong place. + case "$status" in + 0) [ -s "$WORKDIR/openRepoShape" ] || + die "fetched $launcher_url, but it was empty, so nothing was installed." ;; + 22) die "could not fetch $launcher_url: the server answered with an error (curl exit 22), so nothing was installed. Is $version a release of $REPO? https://github.com/$REPO/releases" ;; + *) die "could not fetch $launcher_url: curl got no answer (curl exit $status), so nothing was installed. Check this machine's network, DNS and proxy, then run the line again." ;; + esac looks_like_the_launcher "$WORKDIR/openRepoShape" || die "$launcher_url is not the openRepoShape command (its first line is not #!/usr/bin/env bash, or it has no INSTALLABLES line), so nothing was installed." say "fetch: $launcher_url" bin_dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" target="$bin_dir/openRepoShape" - say "install: OPENREPOSHAPE_REF=$version openRepoShape --install" - OPENREPOSHAPE_REF="$version" "${BASH:-bash}" "$WORKDIR/openRepoShape" --install || + say "install: OPENREPOSHAPE_REF=$version OPENREPOSHAPE_RECORD_REF=1 openRepoShape --install" + OPENREPOSHAPE_REF="$version" OPENREPOSHAPE_RECORD_REF=1 \ + "${BASH:-bash}" "$WORKDIR/openRepoShape" --install || die "the $version openRepoShape's --install refused (exit $?); its own refusal above says why." # THE PIN, CHECKED BY ASKING THE INSTALLED COMMAND, with the variable @@ -326,36 +354,56 @@ main() { *"@ $version)") ;; *) pinned=0 - say "install: $target does not stay on $version: it says '${shown:-nothing}' to --version. A release older than v1.0.1 cannot record the ref it was installed at; export OPENREPOSHAPE_REF=$version to keep it there." + say "install: $target does not stay on $version: it says '${shown:-nothing}' to --version. A release older than v1.0.1 cannot record the tag it was installed at: install v1.0.1 or newer, or export OPENREPOSHAPE_REF=$version in your shell profile to keep it there." ;; esac if [ -n "${OPENREPOSHAPE_REF:-}" ] && [ "$OPENREPOSHAPE_REF" != "$version" ]; then - say "install: \$OPENREPOSHAPE_REF is set in this shell, and while it is, every run uses it instead of $version. Unset it to run $version." + say "install: \$OPENREPOSHAPE_REF is set in this shell, by your shell profile or by hand, and while it is, every run uses it instead of $version. Remove it from your shell profile to run $version." fi # 3. GIT-FILTER-REPO. filter_repo_step - # 4. THE PREFLIGHT, LAST, THROUGH THE INSTALLED COMMAND AND ITS RECORD. + # 4. THE PREFLIGHT, LAST, THROUGH THE INSTALLED COMMAND AND ITS RECORD, so + # its setup.sh is the release's. Where the command did not record the tag, + # the tag is handed to this one run instead, so that a command which + # follows main is still not checked with main's setup.sh. say "preflight: $target --preflight" status=0 - ( - unset OPENREPOSHAPE_REF - "${BASH:-bash}" "$target" --preflight - ) || status=$? + if [ "$pinned" -eq 1 ]; then + ( + unset OPENREPOSHAPE_REF + "${BASH:-bash}" "$target" --preflight + ) || status=$? + else + OPENREPOSHAPE_REF="$version" "${BASH:-bash}" "$target" --preflight || status=$? + fi - if [ "$status" -ne 0 ]; then - say "done: openRepoShape $version is installed at $target, but the preflight did not pass (exit $status); it names what is missing above." + # EXIT 1 IS THE PREFLIGHT'S "not ready", which names what is missing; any + # other status is a run that did not get that far (setup.sh could not be + # fetched, or there is no Python to run it), and its REFUSED line says so. + case "$status" in + 0) ;; + 1) + say "done: openRepoShape $version is installed at $target, but the preflight did not pass (exit 1); it names what is missing above." exit 1 - fi + ;; + *) + say "done: openRepoShape $version is installed at $target, but the preflight did not run to the end (exit $status); the REFUSED line above says why." + exit 1 + ;; + esac if [ "$pinned" -eq 0 ]; then say "done: openRepoShape $version is installed at $target and this machine is ready, but the command does not stay on $version (see above)." exit 1 fi - say "done: openRepoShape $version is installed at $target, and this machine is ready. Run the same line again to upgrade to the newest release." + say "done: openRepoShape $version is installed at $target, pinned to the release tag, and this machine is ready. Run the same line again to upgrade to the newest release." exit 0 } # `${1+"$@"}`, setup.sh's own spelling: no arguments is the one-line run, and -# that has to survive `set -u` under every bash this has to run on. -main ${1+"$@"} +# that has to survive `set -u` under every bash this has to run on. In +# BRACES, so the call is one compound command that bash reads to its closing +# brace before running any of it: a download cut anywhere inside this line +# is a syntax error, and runs nothing. +{ main ${1+"$@"}; } diff --git a/openRepoShape b/openRepoShape index 246bf217..bee32cde 100755 --- a/openRepoShape +++ b/openRepoShape @@ -4,7 +4,7 @@ # openRepoShape — the short way in: `openRepoShape Atlas --org `. # # Install it once, from anywhere, with no checkout and no clone. The newest -# release, pinned to it (`install.sh`, #564): +# release, pinned to its tag (`install.sh`, #564): # # curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash # @@ -39,13 +39,21 @@ SELF="${BASH_SOURCE[0]:-$0}" # chose: setup.sh is told it (`--shape-ref`) and `--doctor` checks its clone # out at it, where an unnamed `main` leaves both to the default branch. Two # things name one, and the first wins: `$OPENREPOSHAPE_REF`, set for a run, -# and the ref `--install` RECORDED beside this file (`$SELF.ref`, read by -# `read_recorded_ref` below, once `checked_ref` exists to check it). With +# and the ref `--install` RECORDED beside this file (`.ref`, read by +# `read_recorded_ref` below, once `ref_fault` exists to check it). With # neither, it is `main`, unnamed, exactly as before there was a record. REF="${OPENREPOSHAPE_REF:-main}" REF_NAMED=0 [ -z "${OPENREPOSHAPE_REF:-}" ] || REF_NAMED=1 +#: 1 when the ref in force came from the record, which is what lets a +#: command re-installing itself keep it with no opt-in (see `record_ref`). +REF_FROM_RECORD=0 + +#: Why the record beside this file was refused, said once the arguments are +#: read, so that `--help` still answers past a broken record. +RECORD_FAULT="" + #: The record's name is the command's own plus this, so the record of #: `~/.local/bin/openRepoShape` is `~/.local/bin/openRepoShape.ref`. RECORD_SUFFIX=".ref" @@ -113,14 +121,17 @@ opensoft/openRepoTools', and its own installer places them: $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one - -`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the -command it places: $OPENREPOSHAPE_REF, which the one-line installer -(install.sh) sets to the release it installs. The installed command then -stays on that ref: it fetches setup.sh at it, setup.sh clones the standard -at it (`--shape-ref`), and `--doctor` checks its clone out at it. -$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the -ref in force. Installed with no ref, it records none and follows main. + $OPENREPOSHAPE_RECORD_REF 1: --install records $OPENREPOSHAPE_REF (below) + +`--install` RECORDS A REF ONLY WHEN ASKED: with $OPENREPOSHAPE_RECORD_REF=1 +it writes $OPENREPOSHAPE_REF to `openRepoShape.ref` beside the command it +places, which is what the one-line installer (install.sh) does with the +release tag it installs. The installed command then stays on that ref: it +fetches setup.sh at it, setup.sh clones the standard at it (`--shape-ref`), +and `--doctor` checks its clone out at it. $OPENREPOSHAPE_REF set for one run +still wins, and `--version` prints the ref in force. A command re-installing +itself keeps its own record; any other install records nothing, removes a +record already there, and follows main. `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: @@ -189,6 +200,10 @@ workdir() { # curl is the fallback for a machine with no `gh`. Each attempt writes to a # file and is checked before its bytes are used, so a half-written first # attempt is never mistaken for a fetch. +# +# HTTPS ONLY, REDIRECTS INCLUDED, TLS 1.2 OR NEWER (#564, SonarCloud +# shell:S6506): the flags are written out on the curl line itself, never +# gathered into an array, so a reader and the analyser both see them there. fetch_from_repo() { local path="$1" dest="$2" if command -v gh >/dev/null 2>&1 && @@ -198,7 +213,8 @@ fetch_from_repo() { return 0 fi if command -v curl >/dev/null 2>&1 && - curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/$path" \ + curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + "https://raw.githubusercontent.com/$REPO/$REF/$path" \ >"$dest" 2>/dev/null && [ -s "$dest" ]; then return 0 fi @@ -329,23 +345,27 @@ machine_report() { # command copied away without its record follows `main` again, and says so in # `--version`. # -# THE REF RECORDED IS THE ONE IN FORCE: `$OPENREPOSHAPE_REF`, else the record -# beside the copy being run, so a pinned command re-installing itself stays -# pinned. With neither, any record already there is REMOVED, which is what -# makes an install at main follow main however the command was installed -# before. Every outcome is one line, and no ref and no record is no line at -# all. The record's old value is echoed only when it passes the same shape -# check, because a hand-edited file is not this command's to print. +# RECORDED ONLY WHEN ASKED (#564, the review on #577). `$OPENREPOSHAPE_REF` +# alone records nothing, because a script that sets it around `--install` +# for its own reasons is not asking for a pin: workBenches sets it to a value +# that names no ref at all, to make any fetch fail loudly, and a record of +# that would break every later run on every machine it installs to. So the +# ref in force is written only when `$OPENREPOSHAPE_RECORD_REF` is 1, as the +# one-line installer sets it, or when it came from the record beside the copy +# being run, so a pinned command re-installing itself stays pinned. Any other +# install is an install with no ref: it records nothing, and REMOVES a record +# already there, which makes an install at main follow main however the +# command was installed before. Every outcome is one line, and nothing to +# record and no record is no line at all. The record's old value is echoed +# only when it is a ref this command would accept, because a hand-edited +# file is not this command's to print. record_ref() { - local record="$1$RECORD_SUFFIX" was="" + local record="$1" recording="$2" was="" if [ -f "$record" ]; then was="$(< "$record")" || was="" - case "$was" in - '' | [!A-Za-z0-9]* | *..* | *[!A-Za-z0-9._/-]*) was="another ref" ;; - esac - [ "${#was}" -le 255 ] || was="another ref" + [ -z "$(ref_fault "$was" "it")" ] || was="another ref" fi - if [ "$REF_NAMED" -eq 0 ]; then + if [ "$recording" -eq 0 ]; then [ -e "$record" ] || return 0 rm -f -- "$record" say "openRepoShape: no longer pinned to ${was:-another ref}; it follows main, or \$OPENREPOSHAPE_REF where set (removed $record)" @@ -359,11 +379,20 @@ record_ref() { install_commands() { local dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" - local name target source verb placed=0 - # THE REF IS CHECKED BEFORE ANYTHING IS COLLECTED OR PLACED: it is about - # to be written down and read back by every later run, and a refusal - # after the copy would leave a new command beside an old record. - [ "$REF_NAMED" -eq 0 ] || checked_ref "$REF" + local name target source verb placed=0 recording=0 record + if [ "$REF_FROM_RECORD" -eq 1 ] || + { [ "$REF_NAMED" -eq 1 ] && [ "${OPENREPOSHAPE_RECORD_REF:-}" = 1 ]; }; then + recording=1 + fi + # THE RECORD SITS BESIDE THE FILE, symlinks followed, as it is read. + record="$(resolved "$dir/openRepoShape")$RECORD_SUFFIX" + # BOTH CHECKED BEFORE ANYTHING IS COLLECTED OR PLACED: a ref about to be + # written down and read back by every later run, and a record path this + # install could neither write nor remove. A refusal after the copy would + # leave a new command beside an old record. + [ "$recording" -eq 0 ] || checked_ref "$REF" + [ ! -e "$record" ] || [ -f "$record" ] || + die "$record is not a file, so this install could neither record its ref there nor remove it. Move it away, then run this again." collect_commands mkdir -p "$dir" for name in "${INSTALLABLES[@]}"; do @@ -382,7 +411,7 @@ install_commands() { done # The record belongs to the one command that reads it, and is written # only once that command is in place. - record_ref "$dir/openRepoShape" + record_ref "$record" "$recording" # THE COUNT, out loud, and kept at one installable (#92): it costs a line # and is what keeps $INSTALLABLES honest — a person reading N lines cannot # tell whether an N+1th was meant to be there. The sentence that made @@ -475,43 +504,94 @@ shape_checkout() { # this script has said a word; and only then the SHAPE, which is safe to echo # back because it has passed both. # -# WHOSE REF IT IS, AND THE WAY OUT, ARE ARGUMENTS (#564): `$OPENREPOSHAPE_REF` -# by default, or the record `--install` wrote, whose refusal also says how to -# replace it. An EMPTY ref is refused too, which no caller could reach before -# the record: an empty variable is no ref at all, while an empty file is one. -checked_ref() { - local ref="$1" what="${2:-\$OPENREPOSHAPE_REF}" out="${3:+ $3}" - [ -n "$ref" ] || - die "$what is empty, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" - if [ "${#ref}" -gt 255 ]; then - die "$what is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters.$out" +# WHOSE REF IT IS IS AN ARGUMENT (#564): `$OPENREPOSHAPE_REF` by default, or +# the record `--install` wrote. `ref_fault` SAYS what is wrong and refuses +# nothing, so the record can be checked as it is read and refused only once +# the arguments are, after `--help` has had its chance; `checked_ref` is the +# refusing spelling. An EMPTY ref is a fault too, which no caller could reach +# before the record: an empty variable is no ref at all, while an empty file +# is one. +ref_fault() { + local ref="$1" what="$2" + if [ -z "$ref" ]; then + printf '%s' "$what is empty, which is not a branch, tag or commit this tool will pass to \`git checkout\`." + elif [ "${#ref}" -gt 255 ]; then + printf '%s' "$what is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters." + else + case "$ref" in + *[![:print:]]*) printf '%s' "$what carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`." ;; + [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) printf '%s' "$what is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'." ;; + esac fi - case "$ref" in - *[![:print:]]*) die "$what carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" ;; - esac - case "$ref" in - [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "$what is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'.$out" ;; - esac +} + +checked_ref() { + local fault + fault="$(ref_fault "$1" "${2:-\$OPENREPOSHAPE_REF}")" + [ -z "$fault" ] || die "$fault" +} + +# A PATH WITH ITS SYMLINKS FOLLOWED (#564, the review on #577). The record +# sits beside the FILE `--install` placed, so a link to the command from +# anywhere else (`~/bin/openRepoShape -> ~/.local/bin/openRepoShape`) must +# read the record there, not look for one beside the link. `readlink` with +# no `-f`, which macOS before 12.3 lacks, one link at a time and at most 40 +# of them, the kernel's own limit, so a loop of links ends. Where `readlink` +# fails or is missing the path is used as it is, which is where the record +# was looked for before. Git Bash makes copies for `ln -s` unless told +# otherwise, and a copy is no link. +resolved() { + local path="$1" link hops=0 + while [ -L "$path" ] && [ "$hops" -lt 40 ]; do + link="$(readlink -- "$path" 2>/dev/null)" && [ -n "$link" ] || break + case "$link" in + /*) path="$link" ;; + *) path="$(dirname -- "$path")/$link" ;; + esac + hops=$((hops + 1)) + done + printf '%s' "$path" } # THE RECORD `--install` LEFT BESIDE THIS FILE, READ ONCE, BEFORE ANY RUN # (#564). Only when no `$OPENREPOSHAPE_REF` was set, which is what lets that # variable win for a run, and only beside a FILE: a script arriving on stdin -# has no place of its own to have been installed at. CHECKED HERE, AT ONCE, -# rather than where the ref is used: it is a file on disk that every run +# has no place of its own to have been installed at. CHECKED HERE, as it is +# read, rather than where the ref is used: it is a file on disk that every run # reads, `--version` prints it, and a value nobody typed is not echoed until -# it has passed. `$(< file)` is bash's own read, so no `cat` is needed. +# it has passed. A record that fails is NOT refused here but kept in +# `$RECORD_FAULT`, and `refuse_bad_record` refuses with it once the arguments +# have been read: `--help` answers past a broken record, and every run that +# would use the ref refuses, `--version` first. `$(< file)` is bash's own +# read, so no `cat` is needed; a record that is not a readable FILE (a +# directory, say) is a fault in the same words as the rest, never a raw bash +# error. +RECORD_WAY_OUT="Run the installer again to record a release, or delete that file to follow main." read_recorded_ref() { - local record="$SELF$RECORD_SUFFIX" - [ "$REF_NAMED" -eq 0 ] && [ -f "$SELF" ] && [ -f "$record" ] || return 0 - REF="$(< "$record")" || - die "could not read $record, the ref \`--install\` recorded. Run the installer again, or delete that file to follow main." - checked_ref "$REF" "the ref recorded in $record" \ - "Run the installer again to record a release, or delete that file to follow main." + local record text fault + [ "$REF_NAMED" -eq 0 ] && [ -f "$SELF" ] || return 0 + record="$(resolved "$SELF")$RECORD_SUFFIX" + [ -e "$record" ] || return 0 + if [ ! -f "$record" ] || [ ! -r "$record" ]; then + RECORD_FAULT="$record is not a file this command can read, so it cannot read the ref \`--install\` recorded there. Remove it, then run the installer again to record a release, or leave it removed to follow main." + return 0 + fi + text="$(< "$record")" + fault="$(ref_fault "$text" "the ref recorded in $record")" + if [ -n "$fault" ]; then + RECORD_FAULT="$fault $RECORD_WAY_OUT" + return 0 + fi + REF="$text" REF_NAMED=1 + REF_FROM_RECORD=1 } read_recorded_ref +refuse_bad_record() { + [ -z "$RECORD_FAULT" ] || die "$RECORD_FAULT" +} + # `--doctor`: RUN THE STANDARD'S OWN SCRIPT, never a reimplementation here. # Beside a checkout -- this file sitting in one, which is how a developer runs # it -- that checkout answers, and nothing is fetched at all. Installed, or @@ -603,7 +683,7 @@ run_doctor() { while [ $# -gt 0 ]; do case "$1" in -h | --help) usage; exit 0 ;; - --version) say "openRepoShape ($REPO @ $REF)"; exit 0 ;; + --version) refuse_bad_record; say "openRepoShape ($REPO @ $REF)"; exit 0 ;; # TWO MODES IS A REFUSAL, NEVER LAST-WINS. `--doctor --install` # used to enter the install path and silently drop the diagnosis, and # the other order dropped the install (Copilot, PR #96). Each of these @@ -696,6 +776,10 @@ while [ $# -gt 0 ]; do esac done +# A BROKEN RECORD IS REFUSED HERE, after `--help` has had its chance and +# before any run that would use the ref it should have named (#564). +refuse_bad_record + if [ "$MODE" = "doctor" ]; then # ONE PATH AND THE DOCTOR'S TWO FLAGS, NOTHING ELSE. Every other # argument this command knows is about a run that CREATES something, and diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 7ced9afc..34c7de5a 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -57,6 +57,11 @@ RELEASE = "v9.9.9" FILTER_REPO = "git-filter-repo==2.47.0" +#: The flags every curl in the installer and the launcher carries, as the +#: fakes log them once the shell has taken the quotes off (SonarCloud +#: shell:S6506: HTTPS only, redirects included, TLS 1.2 or newer). +HTTPS_ONLY = "--proto =https --proto-redir =https --tlsv1.2" + def latest(slug: str = SLUG) -> str: return f"https://github.com/{slug}/releases/latest" @@ -104,12 +109,16 @@ def launcher(directory, name): def curl(): url = next((arg for arg in ARGS if arg.startswith("https://")), "") + if url in NET.get("unreachable", []): + sys.stderr.write("curl: (6) Could not resolve host\n") + return 6 if "-fsSI" in ARGS: location = NET["redirects"].get(url) if location is None: return refuse("curl: (22) The requested URL returned error: 404") - sys.stdout.write("HTTP/2 302 \r\nlocation: %s\r\n" - "content-length: 0\r\n\r\n" % location) + sys.stdout.write("HTTP/2 302 \r\n%s: %s\r\n" + "content-length: 0\r\n\r\n" + % (NET.get("location_header", "location"), location)) return 0 served = NET["files"].get(url) if served is None: @@ -162,6 +171,7 @@ def setup_sh(): case "$1" in --install) mkdir -p "$OPENREPOSHAPE_BIN_DIR" && cp "$0" "$OPENREPOSHAPE_BIN_DIR/openRepoShape" ;; --version) printf 'openRepoShape (%s @ %s)\\n' "$OPENREPOSHAPE_REPO" "${OPENREPOSHAPE_REF:-main}" ;; +--preflight) printf 'old preflight at %s\\n' "${OPENREPOSHAPE_REF:-main}" ;; esac """ @@ -214,18 +224,21 @@ def serve(self, ref: str, slug: str = SLUG, self.config["files"][raw("setup.sh", ref, slug)] = str( self.fake("setup.sh", served)) - def run(self, *args: str, piped: bool = False, - env: dict | None = None) -> subprocess.CompletedProcess: - """`bash install.sh `, or `… | bash -s -- ` when piped.""" + def run(self, *args: str, piped: bool = False, env: dict | None = None, + script: str | None = None) -> subprocess.CompletedProcess: + """`bash install.sh `, or `… | bash -s -- ` when piped; + `script` pipes that text in place of the installer's own.""" (self.root / "net.json").write_text(json.dumps(self.config), encoding="utf-8") gh_config = self.tmp_path / "gh-config" gh_config.mkdir(exist_ok=True) + piped = piped or script is not None argv = [BASH, "-s", "--", *args] if piped \ else [BASH, str(INSTALLER), *args] return subprocess.run( argv, capture_output=True, text=True, check=False, - input=INSTALLER.read_text(encoding="utf-8") if piped else "", + input=(script or INSTALLER.read_text(encoding="utf-8")) + if piped else "", cwd=str(self.tmp_path), start_new_session=True, env=command_env(home=self.tmp_path, setup_sh=None, env={ "PATH": str(self.path), "GH_TOKEN": "bogus", @@ -283,22 +296,24 @@ def test_the_one_line_installs_the_newest_release_pinned_to_it(net): steps = [ f"release: {RELEASE}, the newest release of {SLUG} ({latest()})", f"fetch: {raw('openRepoShape')}", - f"install: OPENREPOSHAPE_REF={RELEASE} openRepoShape --install", + f"install: OPENREPOSHAPE_REF={RELEASE} OPENREPOSHAPE_RECORD_REF=1 " + "openRepoShape --install", f"openRepoShape: pinned to {RELEASE}, recorded in {target}.ref", "git-filter-repo: ", f"preflight: {target} --preflight", - f"done: openRepoShape {RELEASE} is installed at {target}, and this " - "machine is ready. Run the same line again to upgrade to the newest " - "release.", + f"done: openRepoShape {RELEASE} is installed at {target}, pinned to " + "the release tag, and this machine is ready. Run the same line again " + "to upgrade to the newest release.", ] lines = result.stdout.splitlines() at = [next(i for i, line in enumerate(lines) if line.startswith(step)) for step in steps] assert at == sorted(at), result.stdout calls = net.calls() - assert calls[0] == f"curl -fsSI {latest()}" - assert calls[1].startswith(f"curl -fsSL {raw('openRepoShape')} -o ") - assert f"curl -fsSL {raw('setup.sh')}" in calls + assert calls[0] == f"curl -fsSI {HTTPS_ONLY} {latest()}" + assert calls[1].startswith( + f"curl -fsSL {HTTPS_ONLY} {raw('openRepoShape')} -o ") + assert f"curl -fsSL {HTTPS_ONLY} {raw('setup.sh')}" in calls assert calls[-1] == preflight_call(), calls assert not any("releases/latest" in call for call in calls[1:]), calls @@ -375,15 +390,30 @@ def test_a_version_that_is_not_a_ref_is_refused_before_any_request( net.refused(result) assert said in result.stderr assert net.calls() == [] - assert "forged" not in result.stderr and "a" * 256 not in result.stderr + assert "forged" not in result.stderr + assert "a" * 256 not in result.stderr def test_a_failed_fetch_names_its_url_and_installs_nothing(net): + """An error from the server asks whether the release exists.""" del net.config["files"][raw("openRepoShape")] result = net.run() net.refused(result) - assert f"could not fetch {raw('openRepoShape')} (curl exit 22), so " \ - "nothing was installed" in result.stderr + assert f"could not fetch {raw('openRepoShape')}: the server answered " \ + "with an error (curl exit 22), so nothing was installed. Is " \ + f"{RELEASE} a release of {SLUG}?" in result.stderr + + +def test_a_fetch_with_no_answer_points_at_the_network_not_the_release(net): + """No answer at all (DNS, a proxy, TLS) is not a question about the + release, and the refusal does not send anyone looking there.""" + net.config["unreachable"] = [raw("openRepoShape")] + result = net.run() + net.refused(result) + assert f"could not fetch {raw('openRepoShape')}: curl got no answer " \ + "(curl exit 6), so nothing was installed. Check this machine's " \ + "network, DNS and proxy" in result.stderr + assert "a release of" not in result.stderr def test_a_page_that_is_not_the_command_is_refused(net): @@ -439,20 +469,29 @@ def no_pipx(net): def no_pipx_but_brew(net): no_pipx(net) net.fake("brew") + net.fake("apt-get") return " brew install git-filter-repo" +def no_pipx_but_apt(net): + no_pipx(net) + net.fake("apt-get") + return f" sudo apt-get install -y pipx && pipx install {FILTER_REPO}" + + @pytest.mark.parametrize("arrange", [ present_on_path, answering_git, pipx_onto_path, pipx_off_path, - pipx_failing, no_pipx, no_pipx_but_brew]) + pipx_failing, no_pipx, no_pipx_but_brew, no_pipx_but_apt]) def test_git_filter_repo_is_said_installed_or_printed(net, arrange): """The three branches, and the ways each can end. PRESENT, on `$PATH` or as `git filter-repo`: said, and pipx is never run. NOT PRESENT AND PIPX IS THERE: said first, then `pipx install` at CI's version, then what came of it. NEITHER: the one command for this - machine, printed and never run. None changes the exit status, and the - step sits between the install and the preflight. + machine, printed and never run: brew's where brew is, pipx from apt where + apt-get is (Debian 12 and Ubuntu 23.04 on refuse `pip install --user`), + and pip otherwise. Nothing runs `sudo` or `apt-get`. None changes the + exit status, and the step sits between the install and the preflight. """ expected = arrange(net) result = net.run() @@ -470,26 +509,35 @@ def test_git_filter_repo_is_said_installed_or_printed(net, arrange): net.calls().index(preflight_call()) else: assert ran_pipx == [], net.calls() - if arrange in (no_pipx, no_pipx_but_brew): + if arrange in (no_pipx, no_pipx_but_brew, no_pipx_but_apt): assert lines[lines.index(expected) - 1] == ( "git-filter-repo: not installed, and there is no pipx to install " "it with. Only adopt-project.py needs it; before adopting a " "repository, run:") assert lines.index(expected) < lines.index( f"preflight: {net.bin}/openRepoShape --preflight") + assert not any(call.split()[0] in ("apt-get", "brew", "sudo") + for call in net.calls()), net.calls() # --- the preflight, the exit status, and running it again -------------------- +@pytest.mark.parametrize("status, said", [ + pytest.param(1, "but the preflight did not pass (exit 1); it names what " + "is missing above.", id="not-ready"), + pytest.param(2, "but the preflight did not run to the end (exit 2); the " + "REFUSED line above says why.", id="did-not-run"), +]) def test_a_preflight_that_does_not_pass_exits_one_with_the_command_placed( - net): - net.config["preflight_exit"] = 1 + net, status, said): + """Exit 1 is the preflight's own "not ready", which names what is + missing; anything else stopped before it could say.""" + net.config["preflight_exit"] = status result = net.run() assert result.returncode == 1, result.stdout + result.stderr net.installed(result) assert f"done: openRepoShape {RELEASE} is installed at " \ - f"{net.bin}/openRepoShape, but the preflight did not pass (exit 1); " \ - "it names what is missing above." in result.stdout + f"{net.bin}/openRepoShape, {said}" in result.stdout def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): @@ -499,8 +547,9 @@ def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): result = net.run(env={"OPENREPOSHAPE_REF": "v0.0.1"}) assert result.returncode == 0, result.stdout + result.stderr net.installed(result) - assert f"install: $OPENREPOSHAPE_REF is set in this shell, and while it " \ - f"is, every run uses it instead of {RELEASE}. Unset it to run " \ + assert "install: $OPENREPOSHAPE_REF is set in this shell, by your " \ + "shell profile or by hand, and while it is, every run uses it " \ + f"instead of {RELEASE}. Remove it from your shell profile to run " \ f"{RELEASE}." in result.stdout assert net.calls()[-1] == preflight_call() @@ -528,7 +577,9 @@ def test_the_same_line_again_changes_nothing_and_then_upgrades(net): def test_a_release_whose_command_records_no_ref_exits_one(net): """A release before v1.0.1 installs a command that follows main. The - installer asks the installed command, says so, and exits 1.""" + installer asks the installed command, says so, and exits 1; and the + preflight it ends with is still handed the release's tag, so it is not + main's setup.sh that checks the machine.""" old = net.root / "old-openRepoShape" old.write_text(OLD_LAUNCHER, encoding="utf-8") net.serve(RELEASE, launcher=old) @@ -538,11 +589,86 @@ def test_a_release_whose_command_records_no_ref_exits_one(net): assert f"install: {net.bin}/openRepoShape does not stay on {RELEASE}: " \ f"it says 'openRepoShape ({SLUG} @ main)' to --version." \ in result.stdout - assert f"export OPENREPOSHAPE_REF={RELEASE}" in result.stdout + assert "A release older than v1.0.1 cannot record the tag it was " \ + "installed at: install v1.0.1 or newer, or export " \ + f"OPENREPOSHAPE_REF={RELEASE} in your shell profile to keep it " \ + "there." in result.stdout + assert f"old preflight at {RELEASE}" in result.stdout.splitlines() assert result.stdout.splitlines()[-1].endswith( f"but the command does not stay on {RELEASE} (see above).") +@pytest.mark.parametrize("header, location", [ + pytest.param("Location", tag_page(RELEASE), id="upper-case"), + pytest.param("location", f"/{SLUG}/releases/tag/{RELEASE}", + id="relative"), +]) +def test_the_redirect_is_read_in_any_case_and_relative(net, header, location): + """HTTP/1.1 may send `Location:`, and a location may be relative; both + name the release the same way.""" + net.config["location_header"] = header + net.config["redirects"][latest()] = location + result = net.run() + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"release: {RELEASE}, the newest release of {SLUG} ({latest()})" \ + in result.stdout.splitlines() + + +def test_openreposhape_bin_dir_is_where_the_command_and_its_record_go(net): + """The installer places, pins and preflights the command where the + launcher puts it, and leaves `~/.local/bin` alone.""" + default = net.bin + net.bin = net.tmp_path / "elsewhere" + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(net.bin)}) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"preflight: {net.bin}/openRepoShape --preflight" \ + in result.stdout.splitlines() + assert not default.exists() + + +#: A curl that RUNS, in either script: tab-indented, alone or as the value +#: of an assignment. Usage text and refusals carry curl lines too, indented +#: with spaces, and are printed, never run. +CURL_RUN = re.compile(r"\t+(\w+=\"\$\()?curl -fsS") + + +@pytest.mark.parametrize("name", ["install.sh", "openRepoShape"]) +def test_every_curl_that_runs_is_https_only_redirects_included(name): + """SonarCloud shell:S6506, held here rather than found there: each curl + line refuses any protocol but HTTPS, for its redirects too, written out + on the line itself.""" + runs = [line for line in (REPO / name).read_text( + encoding="utf-8").splitlines() if CURL_RUN.match(line)] + assert runs, f"{name} runs no curl; this guard has nothing to hold" + for line in runs: + assert "--proto '=https' --proto-redir '=https' --tlsv1.2" in line, ( + f"{name}: a curl that is not HTTPS only:\n{line}") + + +#: Where a download can stop in the last line. Right after the word `main` +#: is the one the braces are for: unbraced, that line would run `main` with +#: none of the arguments, and install the newest release where one was named. +CUTS = [ + pytest.param(lambda text: text[:text.rindex("{ main")], id="before-it"), + pytest.param(lambda text: text[:text.rindex(" ${1+")], id="after-main"), + pytest.param(lambda text: text[:text.rindex(" }")], id="before-the-brace"), +] + + +@pytest.mark.parametrize("cut", CUTS) +def test_a_download_cut_off_in_the_last_line_runs_nothing(net, cut): + """Everything runs from `main`, called on the last line inside braces: + a script that stops anywhere in or before that line asks for nothing, + installs nothing and leaves nothing behind.""" + result = net.run("v9.9.8", + script=cut(INSTALLER.read_text(encoding="utf-8"))) + assert net.calls() == [] + assert result.stdout == "" + assert not net.bin.exists() + + # --- the repository it installs from ---------------------------------------- def test_openreposhape_repo_names_where_everything_comes_from(net): @@ -556,7 +682,8 @@ def test_openreposhape_repo_names_where_everything_comes_from(net): assert result.returncode == 0, result.stdout + result.stderr net.installed(result) assert all(SLUG not in call for call in net.calls()), net.calls() - assert f"curl -fsSL {raw('setup.sh', RELEASE, fork)}" in net.calls() + assert f"curl -fsSL {HTTPS_ONLY} {raw('setup.sh', RELEASE, fork)}" \ + in net.calls() @pytest.mark.parametrize("value", [ @@ -593,4 +720,5 @@ def test_it_installs_the_git_filter_repo_ci_runs(): ci = re.findall(r"git-filter-repo==[0-9.]+", ( REPO / ".github" / "workflows" / "tests.yml").read_text( encoding="utf-8")) - assert ci and set(ci) == {pinned}, (pinned, ci) + assert ci, "tests.yml installs no git-filter-repo" + assert set(ci) == {pinned}, (pinned, ci) diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 59cb0c00..78a66ab0 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -98,7 +98,8 @@ def command_env(home: Path | None = None, env: dict | None = None, """ environ = dict(os.environ) for name in ("OPENREPOSHAPE_ORG", "OPENREPOSHAPE_REF", "OPENREPOSHAPE_REPO", - "OPENREPOSHAPE_BIN_DIR", "OPENREPOSHAPE_SETUP_SH"): + "OPENREPOSHAPE_BIN_DIR", "OPENREPOSHAPE_SETUP_SH", + "OPENREPOSHAPE_RECORD_REF"): environ.pop(name, None) if setup_sh is not None: environ["OPENREPOSHAPE_SETUP_SH"] = str(setup_sh) @@ -1018,14 +1019,17 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, OTHER = "other" -def install_at(tmp_path, ref=None, command=COMMAND): +def install_at(tmp_path, ref=None, command=COMMAND, opt_in=True): """`--install` from `command` into `/bin`, with - `$OPENREPOSHAPE_REF` set to `ref` when one is given; the result, and the - path of the command it places.""" + `$OPENREPOSHAPE_REF` set to `ref` when one is given, and asked to record + it (`$OPENREPOSHAPE_RECORD_REF=1`) unless `opt_in` is false; the result, + and the path of the command it places.""" bin_dir = tmp_path / "bin" env = {"OPENREPOSHAPE_BIN_DIR": str(bin_dir)} if ref is not None: env["OPENREPOSHAPE_REF"] = ref + if opt_in: + env["OPENREPOSHAPE_RECORD_REF"] = "1" return (run_cmd("--install", home=tmp_path, env=env, command=command), bin_dir / "openRepoShape") @@ -1147,12 +1151,78 @@ def test_a_record_that_is_not_a_ref_is_refused_by_name(tmp_path, text, shown): in result.stderr assert "Run the installer again to record a release, or delete that " \ "file to follow main." in result.stderr - assert "forged" not in result.stderr and "a" * 256 not in result.stderr + assert "forged" not in result.stderr + assert "a" * 256 not in result.stderr assert result.stdout == "" + assert run_cmd("--help", command=installed).returncode == 0, ( + "--help must answer past a broken record") assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}).endswith( f"@ {OTHER})") +def test_a_ref_set_without_the_opt_in_is_never_recorded(tmp_path): + """`$OPENREPOSHAPE_REF` alone is not a request for a pin (the review on + #577). A script that sets it around `--install` for its own reasons + (workBenches sets one that names no ref, so that any fetch fails loudly) + gets the install it always got: the same bytes, no record, `main` in + `--version`, and no check of a value it never meant to be read. A record + already there is removed, exactly as by an install with no ref.""" + install_at(tmp_path, PINNED) + result, installed = install_at( + tmp_path, "pinned-by-workBenches-no-fetch", opt_in=False) + assert result.returncode == 0, result.stderr + assert installed.read_bytes() == COMMAND.read_bytes() + assert not record_of(installed).exists() + assert f"no longer pinned to {PINNED}" in result.stdout + assert version_of(installed).endswith("@ main)") + + again, _ = install_at(tmp_path, "bad..ref", opt_in=False) + assert again.returncode == 0, again.stderr + assert "pinned" not in again.stdout + assert not record_of(installed).exists() + + +#: Links to the installed command from `/links`: each pair is a link's +#: name and what it points at, `None` meaning the command's absolute path, +#: and the run goes through the LAST one. +LINKS = [ + pytest.param([("ors", "../bin/openRepoShape")], id="relative"), + pytest.param([("ors", None)], id="absolute"), + pytest.param([("ors", "../bin/openRepoShape"), ("again", "ors")], + id="a-chain"), +] + + +@pytest.mark.parametrize("links", LINKS) +def test_a_link_to_the_installed_command_reads_its_record(tmp_path, links): + """The record sits beside the FILE `--install` placed, so a symlink to + the command from another directory follows the link, or a chain of them, + to read it.""" + _result, installed = install_at(tmp_path, PINNED) + directory = tmp_path / "links" + directory.mkdir() + for name, points_at in links: + (directory / name).symlink_to(points_at or installed) + through = directory / links[-1][0] + assert version_of(through) == \ + f"openRepoShape (opensoft/openRepoShape @ {PINNED})" + + +def test_a_record_that_is_a_directory_is_refused_in_the_usual_form(tmp_path): + """Not a raw bash error, by every run that reads it and by an install + that would write or remove it, which places nothing.""" + _result, installed = install_at(tmp_path, PINNED) + record = record_of(installed) + record.unlink() + record.mkdir() + for result in (run_cmd("--version", command=installed), + install_at(tmp_path, PINNED)[0]): + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith(f"\nREFUSED: {record} is not a file") + assert "Is a directory" not in result.stderr + assert installed.read_bytes() == COMMAND.read_bytes() + + #: WHICH REF EACH LATER RUN USES: recorded at install, then overridden by the #: variable for one run, then neither. The third column is what the run must #: be handed; `None` is `main`, unnamed, which nothing is told. From 322a305e8c6c3fdf7fa1f718ce9ff27e7c9ea708 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:21:55 +0000 Subject: [PATCH 04/14] README.md, docs/handbook.html, AGENTS.md and the v1.0.1 release notes, the fix round on #577: install.sh itself never runs sudo while the preflight it ends with offers what is missing behind a typed yes, the command is pinned to the release tag only when asked, step 3 folds the pin into one paragraph six lines shorter, AGENTS.md says the one-line install is the human's to run, and the notes gain the steps that attach install.sh to the release (#564) - README.md: step 3's paragraph now carries what "pinned" means and the corrected sudo sentence, and leaves the exit statuses to `install.sh --help` and the release notes; the worked example's lead-in is one line. The cap moves to 1444 (from 1450 in d0cdbf4), with the reason updated in test_readme_is_short_enough_to_be_read. - docs/handbook.html: the same paragraph, the same rule in "For AI assistants", the #564 footer clause updated, and the new README blob. - AGENTS.md: "The one-line install (`install.sh`) is theirs to run: relay it, never run it." under "Never answer an install prompt", paid for by tightening the `--allow-upstream-org` paragraph's middle clause; still 490 lines, every rule kept. - docs/release-notes/v1.0.1.md: the corrected sudo sentence, the opt-in (`OPENREPOSHAPE_RECORD_REF=1`) and what a script that exports `$OPENREPOSHAPE_REF` now gets, HTTPS only, Git Bash supported but its tests skip on the Windows job (a known limit, followed up in v1.1.0), and a "Before tagging (remove at tag time)" section: upload install.sh to v1.0.1, check the `releases/latest/download/install.sh` redirect, never attach it to v1.0.0, and publish a later patch on an older line with `--latest=false`. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 ++--- README.md | 24 +++++------- docs/handbook.html | 38 ++++++++---------- docs/release-notes/v1.0.1.md | 75 +++++++++++++++++++++++++----------- tests/test_repo_hygiene.py | 34 ++++++++-------- 5 files changed, 101 insertions(+), 80 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e579245c..993d2d21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,13 +25,13 @@ types `yes` at its prompt; you never answer it for them. No terminal: section 2. **Never answer an install prompt.** The preflight asks `Type yes to continue:` for each thing it offers to run — an install, `gh auth login`, `gh auth setup-git`. The human types it; you never install software or log in for them. +The one-line install (`install.sh`) is theirs to run: relay it, never run it. **Never pass `--allow-upstream-org` on your own initiative.** It guards only -`--org opensoft` itself — scaffolding into opensoft's own namespace, the -upstream owner of openRepoShape. If setup.sh refuses because the organisation -you were given resolves to opensoft, STOP and relay what it said; the fix is -almost always an explicit `--org `. Pass `--allow-upstream-org` -only if the human asks for it in those words. +`--org opensoft`, the namespace of openRepoShape's upstream owner. If setup.sh +refuses because the organisation you were given resolves to opensoft, STOP and +relay what it said; the fix is almost always an explicit `--org `. +Pass `--allow-upstream-org` only if the human asks for it in those words. **Never bypass an organisation ruleset.** A ruleset that refuses a direct push to `main` is doing its job; the exit is in the tool's own failure message (for diff --git a/README.md b/README.md index 147b3cb5..70ded88c 100644 --- a/README.md +++ b/README.md @@ -231,19 +231,14 @@ three repositories are created and the push fails. curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash ``` -`install.sh` is attached to every release, so that line installs the newest: -it reads which one that is from github.com's `releases/latest` redirect (no -`gh`, no login), installs that release's command PINNED to it, installs -`git-filter-repo` with `pipx` when `pipx` is there and it is not, or prints -the one command that would, and ends with `openRepoShape --preflight`. It -never runs `sudo`. `| bash -s -- v1.0.1` installs a named release instead. -Exit 0 is installed and ready, 1 installed but the preflight or the pin needs -you, 2 nothing installed. Run the same line again to upgrade. - -**Pinned** means the command stays on its release while main moves on: -`--install` records the ref in `openRepoShape.ref` beside the command, and the -command fetches `setup.sh`, clones the standard and runs `--doctor` at it. -`$OPENREPOSHAPE_REF` overrides it for one run; `--version` prints the ref. +`install.sh` is attached to every release and installs the newest, with no `gh` +and no login. Its command is PINNED to the release tag, recorded in +`openRepoShape.ref` beside it: `setup.sh` and the standard are fetched at that +tag, not at main. It installs `git-filter-repo` with `pipx` where it can, or +prints the command, and ends with `openRepoShape --preflight`. `install.sh` +itself never runs `sudo`; the preflight it ends with offers what is missing, +each behind a typed `yes` at your terminal. `| bash -s -- v1.0.1` names a +release; the same line again upgrades. By hand, the same command at main, which records no ref and follows main: @@ -400,8 +395,7 @@ records as whose act the election was, and it defaults to the `gh` login. #### Install the command -Two ways in, the short one first. Install `openRepoShape` as a command, the -newest release and pinned to it, as step 3 above does: +Two ways in, the short one first. Install the command as step 3 above does: ```sh curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash diff --git a/docs/handbook.html b/docs/handbook.html index 377f934a..84d28811 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -488,8 +488,8 @@

The fastest path — macOS, Linux, WSL2

curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
 openRepoShape <Project> --org <your-org>

That first line installs ONE command into ~/.local/bin: the - newest release, pinned to it, with git-filter-repo where pipx can - install it, and it ends with openRepoShape --preflight. The + newest release, pinned to its tag, with git-filter-repo where pipx + can install it, and it ends with openRepoShape --preflight. The estate verbs park and resume, for carrying in-flight work to another workstation, are opensoft/openRepoTools’ and have a one-liner of @@ -756,21 +756,15 @@

Install the openRepoShape command, on

Install the openRepoShape command, once, inside WSL

Fetch the flow, in the directory the new project should land in

curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
-

install.sh is attached to every release, so - that line installs the newest: it reads which one that is from github.com’s - releases/latest redirect (no gh, no login), installs that - release’s command PINNED to it, installs git-filter-repo with - pipx when pipx is there and it is not, or prints the one command - that would, and ends with openRepoShape --preflight. It never runs - sudo. | bash -s -- v1.0.1 installs a named release instead. Exit - 0 is installed and ready, 1 installed but the preflight or the pin needs you, 2 nothing - installed. Run the same line again to upgrade.

-

Pinned means the command stays on its - release while main moves on: --install records the ref in - openRepoShape.ref beside the command, and the command fetches - setup.sh, clones the standard and runs --doctor at it. - $OPENREPOSHAPE_REF overrides it for one run; --version prints the - ref.

+

install.sh is attached to every release and + installs the newest, with no gh and no login. Its command is PINNED to the + release tag, recorded in openRepoShape.ref beside it: setup.sh + and the standard are fetched at that tag, not at main. It installs + git-filter-repo with pipx where it can, or prints the command, + and ends with openRepoShape --preflight. install.sh itself never + runs sudo; the preflight it ends with offers what is missing, each behind a + typed yes at your terminal. | bash -s -- v1.0.1 names a + release; the same line again upgrades.

By hand, the same command at main, which records no ref and follows main:

gh api repos/opensoft/openRepoShape/contents/openRepoShape \
     -H 'Accept: application/vnd.github.raw' | bash -s -- --install
@@ -1818,7 +1812,8 @@

For AI assistants

  • Never answer an install prompt. The preflight asks Type yes to continue: for each thing it offers to run — an install, gh auth login, gh auth setup-git. The human types it; you never - install software or log in for them.
  • + install software or log in for them. The one-line install (install.sh) is + theirs to run: relay it, never run it.
  • Never pass --allow-upstream-org on your own initiative. If setup.sh refuses because the organisation you were given resolves to opensoft, STOP and relay what it said; the fix is almost always an explicit --org <their-org>.
  • @@ -1906,10 +1901,11 @@

    For AI assistants

    merged-away verdict, whose row apply drops, added (#502); the line-ending block an adoption appends to a root .gitattributes the source keeps, and its verification as the source’s bytes plus that block, added (#471); the one-line - install, install.sh, which installs the newest release pinned to it, and the - command’s openRepoShape.ref record, added (#564) — a page + install, install.sh, which installs the newest release pinned to its tag, the + command’s openRepoShape.ref record, and the assistant’s rule that the + line is the human’s to run, added (#564) — a page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob 147b3cb5c1d91c96196d6cef09ba984cfa0f6576. Every command, flag, exit code + README.md blob 70ded88ce2ef4b0cd8faf4100d53fcd98d0da522. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

    diff --git a/docs/release-notes/v1.0.1.md b/docs/release-notes/v1.0.1.md index a5c5e3cb..b165c364 100644 --- a/docs/release-notes/v1.0.1.md +++ b/docs/release-notes/v1.0.1.md @@ -1,7 +1,7 @@ # openRepoShape v1.0.1 v1.0.1 is v1.0.0 plus one change: openRepoShape installs in one line, and the -command it installs stays on the release it was installed from (#564). +command it installs stays on the release tag it was installed from (#564). ## What 1.0.1 adds @@ -15,24 +15,26 @@ command it installs stays on the release it was installed from (#564). It reads which release is the newest from github.com's `releases/latest` redirect, with no `gh` and no login. It fetches that release's `openRepoShape` and installs it into `~/.local/bin`, or - `$OPENREPOSHAPE_BIN_DIR`, pinned to the release. It installs + `$OPENREPOSHAPE_BIN_DIR`, pinned to the release tag. It installs `git-filter-repo` 2.47.0 with `pipx` when `pipx` is there and `git-filter-repo` is not, and otherwise prints the one command that - installs it. It ends with `openRepoShape --preflight`. It never runs `sudo` - and installs nothing else. It exits 0 when the command is installed and the - preflight passed, 1 when the command is installed but the preflight did not - pass or the command does not stay on the release, and 2 when it installed - nothing. -- **An installed command that stays on its release.** `openRepoShape - --install` records the ref it runs at, `$OPENREPOSHAPE_REF`, in - `openRepoShape.ref` beside the command it places. The installed command then - fetches `setup.sh` at that ref, `setup.sh` clones the standard at it - (`--shape-ref`), and `--doctor` checks its clone out at it. In v1.0.0 an - installed command fetched main on every run unless `$OPENREPOSHAPE_REF` was - exported. `$OPENREPOSHAPE_REF` set for one run still overrides the record, - and `openRepoShape --version` prints the ref in force. An install with no - ref records nothing, removes any record already there, and follows main. The - installed command is byte for byte the release's own file. + installs it. It ends with `openRepoShape --preflight`. `install.sh` itself + never runs `sudo`; the preflight it ends with offers what is missing, each + behind a typed `yes` at your terminal. Every download is HTTPS only, + redirects included, at TLS 1.2 or newer. It exits 0 when the command is + installed and the preflight passed, 1 when the command is installed but the + preflight did not pass or the command does not stay on the release tag, and + 2 when it installed nothing. +- **An installed command that stays on its release tag.** With + `OPENREPOSHAPE_RECORD_REF=1`, which `install.sh` sets, `openRepoShape + --install` records `$OPENREPOSHAPE_REF` in `openRepoShape.ref` beside the + command it places. The installed command then fetches `setup.sh` at that + ref, `setup.sh` clones the standard at it (`--shape-ref`), and `--doctor` + checks its clone out at it, and so does a symlink to the command. In + v1.0.0 an installed command fetched main on every run unless + `$OPENREPOSHAPE_REF` was exported. `$OPENREPOSHAPE_REF` set for one run + still overrides the record, and `openRepoShape --version` prints the ref in + force. The installed command is byte for byte the release's own file. ## Install and upgrade @@ -46,20 +48,47 @@ command it installs stays on the release it was installed from (#564). - **By hand, at main:** the README's `gh api repos/opensoft/openRepoShape/contents/openRepoShape … | bash -s -- --install` line still installs main's command, which records no ref and follows main. -- **Windows:** `install.sh` is bash, for macOS, Linux and WSL2. Native - Windows still starts with `py setup-project.py`, as the README says. +- **Platforms:** macOS (the stock `/bin/bash` 3.2 is enough), Linux and WSL2. + Git Bash on Windows is supported too, but the tests of `install.sh` and of + the command skip on the Windows CI job, as the command's always have, so + no CI run exercises Git Bash; that is a known limit, followed up in v1.1.0. + Native Windows without Git Bash still starts with `py setup-project.py`, as + the README says. - **A project's pin** on the standard works as before. Follow [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.0.1 release commit. ## A script that runs `--install` -A script that runs `openRepoShape --install` with `$OPENREPOSHAPE_REF` -exported now pins the installed command to that value. If the value is not a -ref the command should run, unset it for the install. To keep the command on a -commit or a tag, set it to that commit or tag. +`openRepoShape --install` records a ref only when it is asked to, with +`OPENREPOSHAPE_RECORD_REF=1`. A script that exports `$OPENREPOSHAPE_REF` +around `--install` for its own reasons gets the install it got from v1.0.0: +nothing is recorded, a record already there is removed, and the command +follows main. To pin the command a script installs, set +`OPENREPOSHAPE_RECORD_REF=1` and set `$OPENREPOSHAPE_REF` to the commit or +tag the command should stay on. ## Known limits v1.0.1 changes nothing else, so its known limits are v1.0.0's: [Known limits in v1.0.0](v1.0.0.md#known-limits). + +## Before tagging (remove at tag time) + + + +The one-line install works only once `install.sh` is an asset of the release +GitHub marks as latest. Nothing in this repository attaches it: + +1. Publish v1.0.1 as the latest release, then attach the file from the + release commit: `gh release upload v1.0.1 install.sh -R opensoft/openRepoShape`. +2. Check the redirect: + `curl -fsSI https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh` + must answer with a `location:` naming the v1.0.1 asset, + `…/releases/download/v1.0.1/install.sh`. +3. Never attach `install.sh` to v1.0.0. Its tree has no `install.sh`, and its + `openRepoShape` cannot record a ref, so the installer would exit 1 over a + command that follows main. +4. Publish a later patch on an older line with `--latest=false` + (`gh release create … --latest=false`), so that `releases/latest`, and + with it the one-line install, keeps naming the newest line. diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index ff065cf7..3bd552a8 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1184,25 +1184,27 @@ def test_readme_is_short_enough_to_be_read(): # shim's answer there changed from a clone of the upstream to a few # lines of its own, and a README that still promised the flag list # would send a reader to a command that no longer prints it. - # 2026-10-14: 1422 -> 1450 — the one-line install, v1.0.1 (#564). Brett + # 2026-10-14: 1422 -> 1444 — the one-line install, v1.0.1 (#564). Brett # Heap: "we need it to be more simple to install ... now and after, we # need a simple install process". Step 3 of the quick start now LEADS # with `curl -fsSL …/releases/latest/download/install.sh | bash`, and - # the worked example's "Install the command" with the same line: - # eleven lines for the two blocks and their lead-ins. Nine say what - # that line does that the old two did not, because each is a thing a - # reader would otherwise meet unannounced on their own machine: it - # reads the newest release from a redirect with no `gh`, it installs - # git-filter-repo with pipx or prints the command, it ends in the - # preflight, it never runs `sudo`, how to name a release, and its three - # exit statuses. Five say what PINNED means — the record beside the - # command, what reads it, and the variable that overrides it — because - # a command that quietly stops following main is a surprise to anyone - # who expected it to. Two re-introduce the existing lines as the - # by-hand way, at main, so neither is lost, and one is the Layout row - # for `install.sh`. - assert len(lines) <= 1450, ( - f"README.md is {len(lines)} lines; the cap is 1450") + # the worked example's "Install the command" with the same line: ten + # lines for the two blocks and the blank lines around them. Nine, step + # 3's paragraph and the blank after it, say what that line does that + # the old two did not, each a thing a reader would otherwise meet + # unannounced on their own machine: it needs no `gh` and no login, the + # command is PINNED to the release tag, where that is recorded and what + # it changes, it installs git-filter-repo with pipx or prints the + # command, it ends in the preflight, `install.sh` itself never runs + # `sudo` while each of the preflight's offers waits for a typed `yes`, + # how to name a release and how to upgrade. Two re-introduce the + # existing lines as the by-hand way, at main, so neither is lost, and + # one is the Layout row for `install.sh`. The review on #577 folded a + # separate "Pinned" paragraph into step 3's and left the exit statuses + # to `install.sh --help` and the release notes, which took the raise + # from 28 lines to 22. + assert len(lines) <= 1444, ( + f"README.md is {len(lines)} lines; the cap is 1444") #: #476: what each document that carries the first rule says an assistant does From 868c9bf886f58cc2901154e1c8c8ce729939c070 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:39:06 +0000 Subject: [PATCH 05/14] docs/release-notes/v1.0.1.md: the "Before tagging" steps are safe to follow in order (land, prove the squash tree or dispatch the three-OS run on it, tag it annotated, create the release with install.sh attached and --verify-tag, check the asset by redirect and by bytes), Git Bash is said to be meant to work but untested until #585, and only install.sh's own downloads are said to be HTTPS only (#564) The second fix round on #577 (RV577). The old checklist published before attaching install.sh, could tag main through `gh release create` with no tag, uploaded an unchecked working-tree file and never tested the release commit. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.0.1.md | 82 +++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/docs/release-notes/v1.0.1.md b/docs/release-notes/v1.0.1.md index b165c364..9acb9734 100644 --- a/docs/release-notes/v1.0.1.md +++ b/docs/release-notes/v1.0.1.md @@ -20,8 +20,9 @@ command it installs stays on the release tag it was installed from (#564). `git-filter-repo` is not, and otherwise prints the one command that installs it. It ends with `openRepoShape --preflight`. `install.sh` itself never runs `sudo`; the preflight it ends with offers what is missing, each - behind a typed `yes` at your terminal. Every download is HTTPS only, - redirects included, at TLS 1.2 or newer. It exits 0 when the command is + behind a typed `yes` at your terminal. Every download `install.sh` makes + is HTTPS only, redirects included, at TLS 1.2 or newer. It exits 0 when the + command is installed and the preflight passed, 1 when the command is installed but the preflight did not pass or the command does not stay on the release tag, and 2 when it installed nothing. @@ -49,11 +50,10 @@ command it installs stays on the release tag it was installed from (#564). `gh api repos/opensoft/openRepoShape/contents/openRepoShape … | bash -s -- --install` line still installs main's command, which records no ref and follows main. - **Platforms:** macOS (the stock `/bin/bash` 3.2 is enough), Linux and WSL2. - Git Bash on Windows is supported too, but the tests of `install.sh` and of - the command skip on the Windows CI job, as the command's always have, so - no CI run exercises Git Bash; that is a known limit, followed up in v1.1.0. - Native Windows without Git Bash still starts with `py setup-project.py`, as - the README says. + Git Bash on Windows is meant to work, as #564 asks, but nothing has run it + yet: the launcher's and the installer's tests skip on the Windows job. + #585 (v1.1.0) runs them under Git Bash. Native Windows without Git Bash + still starts with `py setup-project.py`, as the README says. - **A project's pin** on the standard works as before. Follow [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.0.1 release commit. @@ -78,17 +78,57 @@ v1.0.1 changes nothing else, so its known limits are v1.0.0's: The one-line install works only once `install.sh` is an asset of the release -GitHub marks as latest. Nothing in this repository attaches it: - -1. Publish v1.0.1 as the latest release, then attach the file from the - release commit: `gh release upload v1.0.1 install.sh -R opensoft/openRepoShape`. -2. Check the redirect: - `curl -fsSI https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh` - must answer with a `location:` naming the v1.0.1 asset, - `…/releases/download/v1.0.1/install.sh`. -3. Never attach `install.sh` to v1.0.0. Its tree has no `install.sh`, and its - `openRepoShape` cannot record a ref, so the installer would exit 1 over a - command that follows main. -4. Publish a later patch on an older line with `--latest=false` - (`gh release create … --latest=false`), so that `releases/latest`, and - with it the one-line install, keeps naming the newest line. +GitHub marks as latest. Nothing in this repository attaches it, so the +release is made in this order, and each step is safe to stop after: + +1. **Land and prove the release commit.** Squash-land #577 into + `release/v1.0`; that squash commit is ``. Confirm that its + tree is the tree of the PR head that passed `tests`, `tests-macos` and + `tests-windows`: + + ```sh + git rev-parse ^{tree} ^{tree} + ``` + + The two are equal when `release/v1.0` had no other commits. If they + differ, run `gh workflow run tests.yml --ref release/v1.0 -R opensoft/openRepoShape` + and go on only when all three jobs pass on ``. A push to + `release/v1.0` runs no CI by itself: `tests.yml` runs on a push to main + only. +2. **Tag that commit, annotated, and push the tag:** + + ```sh + git tag -a v1.0.1 -m "openRepoShape v1.0.1" + git push origin refs/tags/v1.0.1 + ``` + +3. **Create the release with `install.sh` attached, in one command,** from a + checkout at `` and with this section cut from a copy of + these notes: + + ```sh + gh release create v1.0.1 install.sh --verify-tag --latest \ + --title "openRepoShape v1.0.1" --notes-file -R opensoft/openRepoShape + ``` + + `--verify-tag` refuses rather than tag whatever the default branch holds. + gh makes a draft, uploads `install.sh`, then publishes it, so the newest + release is never without the file. +4. **Check the published asset, by redirect and by bytes:** + + ```sh + curl -fsSI --proto '=https' --proto-redir '=https' \ + https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh + curl -fsSL --proto '=https' --proto-redir '=https' \ + https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh \ + | cmp - <(git show v1.0.1:install.sh) + ``` + + The first must answer with a `location:` naming the v1.0.1 asset, + `…/releases/download/v1.0.1/install.sh`; the second, run in bash or zsh, + must print nothing and exit 0. +5. **Never attach `install.sh` to v1.0.0.** Its tree has no `install.sh`, and + its `openRepoShape` cannot record a ref, so the installer would exit 1 over + a command that follows main. **Publish a later patch on an older line with + `--latest=false`**, so that `releases/latest`, and with it the one-line + install, keeps naming the newest line. From b2f0be333abaf7892ead4e4e1a02e7ce0a3a7daa Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:55:06 +0000 Subject: [PATCH 06/14] tests/readme-cap.d/564.txt: README.md's cap is 1449, raised by 22 for the one-line install in a ledger file of its own, as main raises it, rather than #577's inline 1422 -> 1444 on release/v1.0 (#564) README.md on main is 1427 lines at its cap (1422 + 5, #550); #564's lines take it to 1449. The reason is #577's, carried with its accounting: ten lines for the two install.sh blocks, nine for step 3's paragraph, two for the by-hand ways kept at main, one for the Layout row. Co-Authored-By: Claude Opus 5.5 --- tests/readme-cap.d/564.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/readme-cap.d/564.txt diff --git a/tests/readme-cap.d/564.txt b/tests/readme-cap.d/564.txt new file mode 100644 index 00000000..7a500425 --- /dev/null +++ b/tests/readme-cap.d/564.txt @@ -0,0 +1,21 @@ ++22 +2026-10-15 +Twenty-two lines for the one-line install, v1.0.1 (#564), carried into main +for v1.1.0 by Brett Heap's ruling 35. On release/v1.0 #577 raised the cap the +old way, 1422 -> 1444, in the test; here the same lines are this file. +Brett Heap: "we need it to be more simple to install ... now and after, we +need a simple install process". Step 3 of the quick start now LEADS with +`curl -fsSL …/releases/latest/download/install.sh | bash`, and the worked +example's "Install the command" with the same line: ten lines for the two +blocks and the blank lines around them (+10). Nine, step 3's paragraph and the +blank after it, say what that line does that the old two did not, each a thing +a reader would otherwise meet unannounced on their own machine: it needs no +`gh` and no login, the command is PINNED to the release tag, where that is +recorded and what it changes, it installs git-filter-repo with pipx or prints +the command, it ends in the preflight, `install.sh` itself never runs `sudo` +while each of the preflight's offers waits for a typed `yes`, how to name a +release and how to upgrade (+9). Two re-introduce the existing lines as the +by-hand way, at main, so neither is lost (+2), and one is the Layout row for +`install.sh` (+1). The review on #577 folded a separate "Pinned" paragraph +into step 3's and left the exit statuses to `install.sh --help` and the +release notes, which took the raise from 28 lines to 22. From 68b999f0cb3725ca2faf3184af0e6222e08ca709 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:55:48 +0000 Subject: [PATCH 07/14] =?UTF-8?q?docs/handbook.html:=20the=20consent=20rul?= =?UTF-8?q?es=20say=20every=20action=20the=20tool=20OFFERS=20is=20a=20type?= =?UTF-8?q?d=20yes=20and=20the=20one-line=20install=20asks=20nothing,=20pl?= =?UTF-8?q?acing=20the=20command=20and=20git-filter-repo=20through=20pipx?= =?UTF-8?q?=20without=20sudo,=20and=20the=20eight=20sections=20that=20read?= =?UTF-8?q?=20README.md=20=C2=A7=20Starting=20and=20=C2=A7=20Layout=20reco?= =?UTF-8?q?rd=20#564's=20README=20after=20a=20re-read=20(#564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed in README.md since the markers were set is #564 alone: `git diff f6eaab71 HEAD -- README.md` equals `git diff origin/main HEAD -- README.md` (the § Starting markers were set by #579, f6eaab71), and § Layout moved only by the install.sh row since #551 (5e59e3b3) set its marker. Re-read against the merged README, each section in full: - masthead: its fastest path is #577's install.sh line with an accurate summary (newest release, pinned, git-filter-repo where pipx can, ends in the preflight). No change. - start: carries #577's install step, the by-hand ways at main and "Each way installs". One statement was made false: "Every action that changes the machine or the account is a typed yes" stood a few lines below install.sh installing git-filter-repo with pipx unasked. It now scopes the rule to what the tool offers and says what the one-line install does, every fact quoted from README step 3. The machine: block paragraph, the preflight step, Run it and What setup.sh actually runs stay true as written. - partway (the WARNING), ci (private legs), legs (the --explain line), doublepin (the hand-edited pin), park (openRepoTools' line, which --install still prints when install.sh calls it): nothing #564 touched. No change. - layout: carries #577's install.sh row, in README's order; "standard library only" stays true (install.sh is bash, git-filter-repo a tool). No change. Markers: § Starting f0693d8a -> cc78d254 in masthead, start, partway, ci, legs, doublepin, park; § Layout c8be0af4 -> 94701efc in layout; both values as the hygiene check printed them. Co-Authored-By: Claude Opus 5.5 --- docs/handbook.html | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/handbook.html b/docs/handbook.html index 37c4c26f..5c474cac 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -609,7 +609,7 @@

    The one thing to read before electing it

    - +

    Setup

    Start here

    @@ -961,8 +961,11 @@

    Joining a family

    Consent rules, on every platform

      -
    • Every action that changes the machine or the account is a typed yes, at - the same prompt: Type yes to continue:.
    • +
    • Every action the tool offers that changes the machine or the account is a typed + yes, at the same prompt: Type yes to continue:. The one-line + install asks nothing, because running it is your act: it places the command, installs + git-filter-repo with pipx where it can, and itself never runs + sudo.
    • --yes skips the scaffold question only. It does not answer an install offer, gh auth login or gh auth setup-git.
    • With no terminal attached there is no offer at all.
    • @@ -1034,7 +1037,7 @@

      What you have afterwards — the same on every platform

      - +

      Setup

      If it fails part-way

      @@ -1069,7 +1072,7 @@

      If it fails part-way

      - + @@ -1127,7 +1130,7 @@

      Reading private legs in CI

      - + @@ -1363,7 +1366,7 @@

      The InkRouter example

      - + @@ -1659,7 +1662,7 @@

      Is this repository compliant? openRepoShape --doctor

      - + @@ -1814,7 +1817,7 @@

      The degrade rule

      - +

      Reference

      Layout of this repository

      From c1e6b82f841ca81b1ef43c87e49ab61df10d8cd9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 12:43:19 +0000 Subject: [PATCH 08/14] README.md: the NOTE says the preflight installs nothing without the typed yes, beside step 3's one-line install, which installs git-filter-repo with pipx where it can (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Nothing is installed without that typed `yes`" became "The preflight installs nothing without that typed `yes`", and the rest of the sentence is kept. The longer clause re-wraps the NOTE's last eight lines into eight, so README.md stays at 1449, its ledger cap, and every line stays within 80 columns. `py setup-project.py --preflight` stays on one line, because test_the_windows_commands_name_files_that_exist reads `py ` across whitespace. Re-read against the new wording, the seven sections that read § Starting: start's consent rules ("Every action the tool offers ... is a typed yes", "The one-line install asks nothing ...", "no offer at all") and its four "The tool offers the rest" panels already put the typed yes on the preflight's offers; its `machine:` block line is about --install, and partway's "Nothing is created before the typed yes" quotes the WARNING, not the NOTE; masthead, ci, legs, doublepin and park render nothing from the NOTE. No page text changes. The markers move as the hygiene check printed: cc78d254 -> c9aebc08 in masthead, start, partway, ci, legs, doublepin, park. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++-------- docs/handbook.html | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 462a01f0..8d6539be 100644 --- a/README.md +++ b/README.md @@ -174,14 +174,14 @@ run the four steps below INSIDE the WSL shell. > opens with a preflight that names what is missing and, for `gh`, its login and > its credential helper, prints the command for THIS machine and asks once — > `Type yes to continue:`, the same words it asks everything else with — then -> runs it and carries on. Nothing is installed without that typed `yes`; `--yes` -> does not answer it, and with no terminal attached there is no offer at all. -> Homebrew is the one install never run for you: on a Mac without `brew` the -> preflight names and stops there. To check a machine and -> stop, creating nothing, run `openRepoShape --preflight` — `py setup-project.py -> --preflight` on Windows — which exits 0 when everything is present and 1 when -> it is not (`openRepoShape --preflight` exits 2 when `git` or a Python 3.9 or -> newer is missing). +> runs it and carries on. The preflight installs nothing without that typed +> `yes`; `--yes` does not answer it, and with no terminal attached there is no +> offer at all. Homebrew is the one install never run for you: on a Mac without +> `brew` the preflight names and stops there. To check a +> machine and stop, creating nothing, run `openRepoShape --preflight` — +> `py setup-project.py --preflight` on Windows — which exits 0 when everything +> is present and 1 when it is not (`openRepoShape --preflight` exits 2 when +> `git` or a Python 3.9 or newer is missing). **1. Install the GitHub CLI**, `gh`. The official instructions are at (and ): diff --git a/docs/handbook.html b/docs/handbook.html index 3f02d075..4a0ab624 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -609,7 +609,7 @@

      The one thing to read before electing it

      - +

      Setup

      Start here

      @@ -1037,7 +1037,7 @@

      What you have afterwards — the same on every platform

      - +

      Setup

      If it fails part-way

      @@ -1072,7 +1072,7 @@

      If it fails part-way

      - + @@ -1130,7 +1130,7 @@

      Reading private legs in CI

      - + @@ -1366,7 +1366,7 @@

      The InkRouter example

      - + @@ -1663,7 +1663,7 @@

      Is this repository compliant? openRepoShape --doctor

      - + From 5191f0eb9a74a8865550dd66cd5b7ec7ab0eae10 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 12:53:40 +0000 Subject: [PATCH 09/14] docs/handbook.html: the consent rules say install.sh asks nothing before its own two acts, and the preflight it ends with asks before each offer (#564) "The one-line install asks nothing" was not true as written: install.sh ends with `openRepoShape --preflight`, which asks `Type yes to continue:` before each offer, as step 3 above it and README.md:239-241 say. Only install.sh's own two acts, placing the command and installing git-filter-repo with pipx, go unasked. Found in #587's review. Handbook text only, in the same five lines: README.md is unchanged, so no marker moves, and the hygiene check asks for none. Co-Authored-By: Claude Opus 5.5 --- docs/handbook.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/handbook.html b/docs/handbook.html index 4a0ab624..0c3413df 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -962,10 +962,10 @@

      Joining a family

      Consent rules, on every platform

      • Every action the tool offers that changes the machine or the account is a typed - yes, at the same prompt: Type yes to continue:. The one-line - install asks nothing, because running it is your act: it places the command, installs - git-filter-repo with pipx where it can, and itself never runs - sudo.
      • + yes, at the same prompt: Type yes to continue:. install.sh + asks nothing before its own two acts, because running it is your act: it places the + command and installs git-filter-repo with pipx where it can, and itself + never runs sudo. The preflight it ends with asks before each offer, as above.
      • --yes skips the scaffold question only. It does not answer an install offer, gh auth login or gh auth setup-git.
      • With no terminal attached there is no offer at all.
      • From 649b089522dd6034379f25533307d0babe3034b6 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 15:01:54 +0000 Subject: [PATCH 10/14] tests/test_openreposhape_command.py and tests/test_install_sh.py run in the Windows job under Git for Windows' own bash, found beside its git and never from PATH, with paths spelled as Git Bash spells them, so the one-line install and the command it places are tested on Windows, and README and the handbook say Git Bash is supported (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two suites wore WINDOWS_SKIP, so nothing had run `install.sh` or `openRepoShape` under Git Bash, where v1.0.1's one-line install is meant to work. They now run there; the Windows job's PowerShell step runs them with the rest of the suite. tests/conftest.py: - `BASH` is Git for Windows' `usr\bin\bash.exe`, found from `git --exec-path`. A bare `bash` there is System32's WSL launcher, which `subprocess` reaches before PATH ("Windows Subsystem for Linux has no installed distributions"). `bin\bash.exe`, which `shutil.which` finds, puts Git's `mingw64\bin` first on PATH, so Git's real `curl` shadowed the suite's fake one and the old install.sh suite reached github.com. - `BASH_SKIP` replaces `WINDOWS_SKIP` in these two files only. - `bash_path` and `native_path` convert with Git's own `cygpath`. - The `git_bash_session` fixture puts `mingw64\bin`, `usr\bin` and `MSYSTEM` where Git Bash puts them, behind any fake a test puts first. The runner's PATH already holds both; a stock Git for Windows install puts only `Git\cmd` on PATH, and there `mktemp` is missing and `bash` is the WSL launcher. The two test files: - every path handed to bash or expected from it is spelled `/d/a/...`; - every script a test writes for bash is written as LF bytes, and the fake's redirect headers and `gh` tag are written as bytes, because Python on Windows prints `\r\n` and `$(...)` keeps the `\r`; - `bash -s` runs keep `bash` as argv[0] and name `BASH` as the executable; - `--install` is checked as a command by mode 755, or under Git Bash, which keeps no mode bits, by bash's own `test -x`; - a test's own PATH holds `#!/bin/sh` wrappers on Windows, because a symlink to a Git Bash program cannot load its DLL; - an argument holding a newline reaches Git Bash through the environment, because the Windows command line splits it in two; - a new test makes a link with Git Bash's own `ln -s`: by default a copy, which follows main, and with `MSYS=winsymlinks:nativestrict` a link, which reads the record. .github/workflows/tests.yml: a step before the Windows tests fails the job when conftest finds no Git Bash, so the two suites cannot quietly skip; the macOS job's comment no longer says the Windows job skips the launcher. README.md and docs/handbook.html: PowerShell has no twin of the command, and in Git Bash the one-line install and the command are supported and tested; WSL2 runs every bash entry point, no longer "is still how you run" them. README keeps its line count. The seven handbook markers for README.md § Starting a project in a new organisation record its new hash. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 15 ++- README.md | 8 +- docs/handbook.html | 28 ++-- tests/conftest.py | 94 ++++++++++++++ tests/test_install_sh.py | 114 ++++++++++------- tests/test_openreposhape_command.py | 191 +++++++++++++++++++--------- 6 files changed, 319 insertions(+), 131 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aeb0de45..c4f16121 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -194,6 +194,16 @@ jobs: "GIT_COMMITTER_NAME=openRepoShape CI" >> $env:GITHUB_ENV "GIT_COMMITTER_EMAIL=ci@openreposhape.invalid" >> $env:GITHUB_ENV + # GIT BASH, OR THIS JOB FAILS HERE (#585). The suites of `openRepoShape` + # and `install.sh` run under Git for Windows' own bash, which + # `tests/conftest.py` finds beside the git on PATH (`BASH`), never as + # the `bash` PATH names first: on this image that can be System32's + # WSL launcher. Where it finds none they SKIP, and a skip here would + # make README's Git Bash sentence a claim again, so this step refuses + # first and prints the bash the suite will run. + - name: Git Bash is the bash the launcher and installer suites run + run: python -c "import sys; sys.path.insert(0, 'tests'); from conftest import BASH; print(BASH); sys.exit(BASH is None)" + # THE WHOLE SUITE, not a Windows-shaped subset. A subset is a claim # about which tests are platform-dependent, made by whoever wrote the # subset; the suite itself already carries that claim, as the @@ -212,8 +222,9 @@ jobs: run: python -m pytest tests -q --basetemp=${{ runner.temp }}\t # THE SAME SUITE ON macOS, because the BASH entry points are what a Mac runs - # — `setup.sh` and the `openRepoShape` command, whose suites the windows job - # skips — and until this job nothing here had ever run on one. A THIRD JOB + # — `setup.sh`, whose suite the windows job skips, and the `openRepoShape` + # command, which it runs only under Git Bash (#585) — and until this job + # nothing here had ever run on one. A THIRD JOB # rather than a matrix on the first: that job downloads a linux-amd64 # actionlint binary, which belongs on no other runner. tests-macos: diff --git a/README.md b/README.md index 8d6539be..ffb82387 100644 --- a/README.md +++ b/README.md @@ -153,16 +153,16 @@ to the terminal you are typing at. preflight, the same naming check, the same plan, the same one `yes`, the same self-bootstrap outside a checkout. It never needs `make` and never looks for a `python3` — it runs the interpreter that started it. That two-liner IS the -Windows way in; the `openRepoShape` command in step 3 below is a macOS, Linux -and WSL2 convenience with no Windows twin, because on Windows there is nothing -to install. +Windows way in from PowerShell, which has no twin of the `openRepoShape` +command in step 3 below. In Git Bash, the bash that Git for Windows installs, +the one-line install and that command are supported, and CI tests them there.
        Windows via WSL2 -**Windows via WSL2** is still how you run the bash entry points: `wsl +**Windows via WSL2** runs every bash entry point in Linux itself: `wsl --install` from an administrator PowerShell, reboot, open the Ubuntu app, and run the four steps below INSIDE the WSL shell. diff --git a/docs/handbook.html b/docs/handbook.html index 0c3413df..4d779f5a 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -493,7 +493,7 @@

        openRepoShape

        several of them is held together by convention or by nothing. This repository is the convention, as data and as running code.

        -

        The fastest path — macOS, Linux, WSL2

        +

        The fastest path — macOS, Linux, WSL2, Git Bash

        curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
         openRepoShape <Project> --org <your-org>

        That first line installs ONE command into ~/.local/bin: the @@ -503,8 +503,8 @@

        The fastest path — macOS, Linux, WSL2

        in-flight work to another workstation, are opensoft/openRepoTools’ and have a one-liner of their own.

        -

        On Windows without WSL2 the way in is two PowerShell commands instead, - because on Windows there is nothing to install — see Start +

        In PowerShell, without WSL2 or Git Bash, the way in is two commands + instead, because there is nothing to install — see Start here.

    @@ -609,7 +609,7 @@

    The one thing to read before electing it

    - +

    Setup

    Start here

    @@ -720,9 +720,9 @@

    The tool offers the rest

    winget install --id GitHub.cli -e for gh and, only under py setup-project.py --preflight, winget install --id Git.Git -e for git: a run refuses a missing git before its preflight starts. - There is no openRepoShape command to install here: that two-liner IS the - Windows way in, and the openRepoShape command is a macOS, Linux and WSL2 - convenience with no Windows twin, because on Windows there is nothing to install.

    + There is no openRepoShape command to install in PowerShell: that two-liner + IS the Windows way in there. In Git Bash, the bash that Git for Windows installs, the + one-line install and the openRepoShape command are supported, and CI tests them there.

    @@ -731,7 +731,7 @@

    The tool offers the rest

    What this machine needs

    -

    WSL2 is still how you run the bash entry points: wsl --install from an +

    WSL2 runs every bash entry point in Linux itself: wsl --install from an administrator PowerShell, reboot, open the Ubuntu app, and run the steps INSIDE the WSL shell.

    wsl --install
    @@ -1037,7 +1037,7 @@

    What you have afterwards — the same on every platform

    - +

    Setup

    If it fails part-way

    @@ -1072,7 +1072,7 @@

    If it fails part-way

    - + @@ -1130,7 +1130,7 @@

    Reading private legs in CI

    - + @@ -1366,7 +1366,7 @@

    The InkRouter example

    - + @@ -1663,7 +1663,7 @@

    Is this repository compliant? openRepoShape --doctor

    - + diff --git a/tests/conftest.py b/tests/conftest.py index 9f517dc8..8365cda3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ from __future__ import annotations +import functools import importlib.util import os import re @@ -222,6 +223,99 @@ def load_without_bytecode(path: Path, name: str): "point is setup-project.py (tests/test_setup_project_py.py)") +def _git_for_windows() -> Path | None: + """Git for Windows' own directory, from the `git` that runs this suite. + + `git --exec-path` answers `/mingw64/libexec/git-core` there, and + `/usr/bin/bash.exe` is its bash. None off Windows, and where that + git is some other build, with no such bash beside it. + """ + if os.name != "nt": + return None + try: + answer = subprocess.run(["git", "--exec-path"], capture_output=True, + text=True, check=False).stdout.strip() + except OSError: + return None + parents = Path(answer).parents if answer else () + if len(parents) < 3: + return None + root = parents[2] + return root if (root / "usr" / "bin" / "bash.exe").is_file() else None + + +GIT_FOR_WINDOWS = _git_for_windows() + +#: THE BASH THAT `openRepoShape` AND `install.sh` ARE TESTED UNDER (#585). +#: Off Windows, the `bash` on PATH. On Windows, Git Bash: Git for Windows' +#: own `usr\bin\bash.exe`, found beside the git this suite runs and NEVER +#: from PATH, because a bare `bash` there can be `System32\bash.exe`, the WSL +#: launcher, and `subprocess` looks in System32 before it reads PATH at all. +#: `usr\bin` and not `bin`: `bin\bash.exe`, which Git Bash's own shortcut +#: runs, puts Git's `mingw64\bin` and `usr\bin` FIRST on PATH, ahead of a +#: test's fake `curl`, and a test's own PATH of a few programs would hold +#: every one of Git's. `git_bash_session` puts the two there behind the +#: fakes instead. These suites wore `WINDOWS_SKIP` until then. +if os.name == "nt": + BASH = (str(GIT_FOR_WINDOWS / "usr" / "bin" / "bash.exe") + if GIT_FOR_WINDOWS is not None else None) +else: + BASH = shutil.which("bash") + +BASH_SKIP = pytest.mark.skipif( + BASH is None, + reason="needs bash: on Windows, Git for Windows' own usr\\bin\\bash.exe " + "beside the git on PATH, which this machine does not have") + + +@functools.lru_cache(maxsize=None) +def _cygpath(flag: str, path: str) -> str: + """Git for Windows' `cygpath `: the spelling bash itself uses.""" + return subprocess.run( + [str(GIT_FOR_WINDOWS / "usr" / "bin" / "cygpath.exe"), flag, "--", + path], capture_output=True, text=True, check=True).stdout.strip() + + +def bash_path(path) -> str: + """`path` as `BASH` spells it, for its argv, its environment and what it + prints (#585): unchanged off Windows, and `/d/a/...` under Git Bash, from + its own `cygpath -u` — the spelling Git Bash gives `$HOME`, `$TMPDIR`, + `PATH` and `pwd`, and so the one a person there types.""" + if GIT_FOR_WINDOWS is None: + return str(path) + return _cygpath("-u", str(path)) + + +def native_path(text: str) -> Path: + """The other way: a path `BASH` printed, as this interpreter opens it.""" + if GIT_FOR_WINDOWS is None: + return Path(text) + return Path(_cygpath("-w", text)) + + +@pytest.fixture +def git_bash_session(monkeypatch): + """PATH and `MSYSTEM` as Git Bash makes them, for a test that runs + `BASH` (#585); off Windows, nothing. + + Git Bash puts Git's `mingw64\\bin` and `usr\\bin` ahead of the Windows + PATH, which is where a person there finds `mktemp`, `cp` and the bash + that `openRepoShape` runs `setup.sh` with, ahead of System32's WSL + launcher. Here they go ahead of `os.environ["PATH"]`, so a fake a test + puts first still comes first. Neither directory holds a `gh`, so the + session's gh guard still answers. The GitHub runner's own PATH already + holds both, and Git's `bin` before System32; Git for Windows' default + install puts only its `cmd` on PATH, and there, without this, `mktemp` + is not found and `bash` is the WSL launcher. + """ + if GIT_FOR_WINDOWS is None: + return + monkeypatch.setenv("PATH", os.pathsep.join([ + str(GIT_FOR_WINDOWS / "mingw64" / "bin"), + str(GIT_FOR_WINDOWS / "usr" / "bin"), os.environ.get("PATH", "")])) + monkeypatch.setenv("MSYSTEM", "MINGW64") + + def rmtree(path: Path) -> None: """`shutil.rmtree` that also works over a git object store on Windows. diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 34c7de5a..108d49c0 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -24,14 +24,18 @@ `GH_CONFIG_DIR`, and its `TMPDIR` is a directory each test checks is empty again afterwards. -WINDOWS_SKIP, for the reason `tests/test_openreposhape_command.py` gives: the -installer and the command are bash, run where `bash` is a bash, and on -Windows the way in is `setup-project.py`. +On Windows it runs under Git Bash, as `tests/test_openreposhape_command.py` +does (#585): `BASH` is Git for Windows' own, every path is spelled the way +Git Bash spells it, and every script a fake writes has `\n` line ends. What +a fake prints that bash reads into a variable, the redirect's headers and +`gh`'s tag, is written as bytes, because Python on Windows ends a printed +line with `\r\n` and `$(…)` keeps the `\r`. """ from __future__ import annotations import json +import os import re import shlex import shutil @@ -41,17 +45,15 @@ import pytest -from conftest import REPO, WINDOWS_SKIP -from test_openreposhape_command import COMMAND, INSTALL_NEEDS, command_env +from conftest import BASH, BASH_SKIP, REPO, bash_path +from test_openreposhape_command import (COMMAND, INSTALL_NEEDS, command_env, + put_on_path, write_script) INSTALLER = REPO / "install.sh" -#: Bash by ABSOLUTE path: `subprocess` resolves a bare argv[0] against the -#: child's `$PATH`, and the one each run builds holds only what it chose. -BASH = shutil.which("bash") - -pytestmark = [pytest.mark.skipif(BASH is None, reason="install.sh is bash"), - WINDOWS_SKIP] +#: `BASH` is by ABSOLUTE path: `subprocess` resolves a bare argv[0] against +#: the child's `$PATH`, and the one each run builds holds only what it chose. +pytestmark = [BASH_SKIP, pytest.mark.usefixtures("git_bash_session")] SLUG = "opensoft/openRepoShape" RELEASE = "v9.9.9" @@ -101,9 +103,9 @@ def refuse(message): def launcher(directory, name): path = Path(directory) / name - path.write_text("#!/bin/sh\nexec %s %s %s \"$@\"\n" % ( - shlex.quote(sys.executable), shlex.quote(str(Path(__file__))), name), - encoding="utf-8") + path.write_bytes(("#!/bin/sh\nexec %s %s %s \"$@\"\n" % ( + shlex.quote(sys.executable), shlex.quote(str(Path(__file__))), + name)).encode("utf-8")) path.chmod(0o755) @@ -116,9 +118,10 @@ def curl(): location = NET["redirects"].get(url) if location is None: return refuse("curl: (22) The requested URL returned error: 404") - sys.stdout.write("HTTP/2 302 \r\n%s: %s\r\n" - "content-length: 0\r\n\r\n" - % (NET.get("location_header", "location"), location)) + sys.stdout.buffer.write(( + "HTTP/2 302 \r\n%s: %s\r\ncontent-length: 0\r\n\r\n" + % (NET.get("location_header", "location"), location) + ).encode("utf-8")) return 0 served = NET["files"].get(url) if served is None: @@ -134,7 +137,7 @@ def curl(): def gh(): if ARGS[:1] == ["api"] and ARGS[1].endswith("/releases/latest") \ and NET.get("gh_latest"): - print(NET["gh_latest"]) + sys.stdout.buffer.write((NET["gh_latest"] + "\n").encode("utf-8")) return 0 return refuse("fake gh: no answer for: " + " ".join(ARGS)) @@ -195,7 +198,7 @@ def __init__(self, tmp_path: Path, fakes=("curl", "gh", "pipx")): for name in (*INSTALL_NEEDS, "bash"): real = shutil.which(name) assert real, f"this machine has no {name}, which the install needs" - (self.path / name).symlink_to(real) + put_on_path(self.path, name, real) for name in fakes: self.fake(name) self.temporary = tmp_path / "tmp" @@ -208,10 +211,9 @@ def __init__(self, tmp_path: Path, fakes=("curl", "gh", "pipx")): def fake(self, name: str, directory: Path | None = None) -> Path: """A launcher for `name` into `FAKE`, on this `$PATH` by default.""" path = (directory or self.path) / name - path.write_text( - f"#!/bin/sh\nexec {shlex.quote(sys.executable)} " - f"{shlex.quote(str(self.root / 'fake.py'))} {name} \"$@\"\n", - encoding="utf-8") + write_script( + path, f"#!/bin/sh\nexec {shlex.quote(sys.executable)} " + f"{shlex.quote(str(self.root / 'fake.py'))} {name} \"$@\"\n") path.chmod(0o755) return path @@ -232,18 +234,30 @@ def run(self, *args: str, piped: bool = False, env: dict | None = None, encoding="utf-8") gh_config = self.tmp_path / "gh-config" gh_config.mkdir(exist_ok=True) - piped = piped or script is not None - argv = [BASH, "-s", "--", *args] if piped \ - else [BASH, str(INSTALLER), *args] - return subprocess.run( - argv, capture_output=True, text=True, check=False, - input=(script or INSTALLER.read_text(encoding="utf-8")) - if piped else "", - cwd=str(self.tmp_path), start_new_session=True, - env=command_env(home=self.tmp_path, setup_sh=None, env={ - "PATH": str(self.path), "GH_TOKEN": "bogus", - "GH_CONFIG_DIR": str(gh_config), - "TMPDIR": str(self.temporary), **(env or {})})) + how = {"cwd": str(self.tmp_path), "start_new_session": True, + "env": command_env(home=self.tmp_path, setup_sh=None, env={ + "PATH": str(self.path), "GH_TOKEN": "bogus", + "GH_CONFIG_DIR": str(gh_config), + "TMPDIR": str(self.temporary), **(env or {})})} + if piped or script is not None: + return subprocess.run( + [BASH, "-s", "--", *args], capture_output=True, text=True, + check=False, + input=script or INSTALLER.read_text(encoding="utf-8"), **how) + argv = [BASH, bash_path(INSTALLER), *args] + if os.name == "nt" and any("\n" in arg for arg in args): + # A NEWLINE IN AN ARGUMENT DOES NOT CROSS INTO GIT BASH (#585). + # Windows hands a program one command line, Python quotes only + # an argument holding a space or a tab, and Git Bash splits the + # line at a newline too, so `v1\nforged` arrived as two + # versions. A person in Git Bash passes it from bash to bash, + # where nothing is split; here a bash takes the arguments from + # the environment, which keeps them whole, and hands them on. + how["env"].update((f"ARG{at}", arg) for at, arg in enumerate(args)) + argv = [BASH, "-c", 'exec "$BASH" "$0" ' + " ".join( + f'"$ARG{at}"' for at in range(len(args))), argv[1]] + return subprocess.run(argv, capture_output=True, text=True, + check=False, input="", **how) def calls(self) -> list: log = self.root / "calls.log" @@ -292,7 +306,7 @@ def test_the_one_line_installs_the_newest_release_pinned_to_it(net): result = net.run(piped=True) assert result.returncode == 0, result.stdout + result.stderr net.installed(result) - target = net.bin / "openRepoShape" + target = bash_path(net.bin / "openRepoShape") steps = [ f"release: {RELEASE}, the newest release of {SLUG} ({latest()})", f"fetch: {raw('openRepoShape')}", @@ -433,7 +447,8 @@ def test_a_page_that_is_not_the_command_is_refused(net): def present_on_path(net): net.fake("git-filter-repo") - return f"git-filter-repo: already installed ({net.path}/git-filter-repo)" + return "git-filter-repo: already installed " \ + f"({bash_path(net.path)}/git-filter-repo)" def answering_git(net): @@ -515,7 +530,7 @@ def test_git_filter_repo_is_said_installed_or_printed(net, arrange): "it with. Only adopt-project.py needs it; before adopting a " "repository, run:") assert lines.index(expected) < lines.index( - f"preflight: {net.bin}/openRepoShape --preflight") + f"preflight: {bash_path(net.bin)}/openRepoShape --preflight") assert not any(call.split()[0] in ("apt-get", "brew", "sudo") for call in net.calls()), net.calls() @@ -537,7 +552,7 @@ def test_a_preflight_that_does_not_pass_exits_one_with_the_command_placed( assert result.returncode == 1, result.stdout + result.stderr net.installed(result) assert f"done: openRepoShape {RELEASE} is installed at " \ - f"{net.bin}/openRepoShape, {said}" in result.stdout + f"{bash_path(net.bin)}/openRepoShape, {said}" in result.stdout def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): @@ -561,10 +576,10 @@ def test_the_same_line_again_changes_nothing_and_then_upgrades(net): assert net.run().returncode == 0 again = net.run() assert again.returncode == 0, again.stdout + again.stderr - assert f"openRepoShape: already installed at {net.bin}/openRepoShape " \ - "(unchanged)" in again.stdout + assert "openRepoShape: already installed at " \ + f"{bash_path(net.bin)}/openRepoShape (unchanged)" in again.stdout assert f"openRepoShape: already pinned to {RELEASE} in " \ - f"{net.bin}/openRepoShape.ref (unchanged)" in again.stdout + f"{bash_path(net.bin)}/openRepoShape.ref (unchanged)" in again.stdout net.config["redirects"][latest()] = tag_page("v10.0.0") net.serve("v10.0.0") @@ -572,7 +587,7 @@ def test_the_same_line_again_changes_nothing_and_then_upgrades(net): assert upgrade.returncode == 0, upgrade.stdout + upgrade.stderr net.installed(upgrade, "v10.0.0") assert f"openRepoShape: pinned to v10.0.0 (was {RELEASE}), recorded in " \ - f"{net.bin}/openRepoShape.ref" in upgrade.stdout + f"{bash_path(net.bin)}/openRepoShape.ref" in upgrade.stdout def test_a_release_whose_command_records_no_ref_exits_one(net): @@ -581,12 +596,13 @@ def test_a_release_whose_command_records_no_ref_exits_one(net): preflight it ends with is still handed the release's tag, so it is not main's setup.sh that checks the machine.""" old = net.root / "old-openRepoShape" - old.write_text(OLD_LAUNCHER, encoding="utf-8") + write_script(old, OLD_LAUNCHER) net.serve(RELEASE, launcher=old) - result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(net.bin), + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": bash_path(net.bin), "OPENREPOSHAPE_REPO": SLUG}) assert result.returncode == 1, result.stdout + result.stderr - assert f"install: {net.bin}/openRepoShape does not stay on {RELEASE}: " \ + assert f"install: {bash_path(net.bin)}/openRepoShape does not stay on " \ + f"{RELEASE}: " \ f"it says 'openRepoShape ({SLUG} @ main)' to --version." \ in result.stdout assert "A release older than v1.0.1 cannot record the tag it was " \ @@ -620,10 +636,10 @@ def test_openreposhape_bin_dir_is_where_the_command_and_its_record_go(net): launcher puts it, and leaves `~/.local/bin` alone.""" default = net.bin net.bin = net.tmp_path / "elsewhere" - result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(net.bin)}) + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": bash_path(net.bin)}) assert result.returncode == 0, result.stdout + result.stderr net.installed(result) - assert f"preflight: {net.bin}/openRepoShape --preflight" \ + assert f"preflight: {bash_path(net.bin)}/openRepoShape --preflight" \ in result.stdout.splitlines() assert not default.exists() @@ -701,7 +717,7 @@ def test_a_repository_that_is_not_an_owner_and_name_is_refused(net, value): def test_help_prints_the_one_line_and_exits_zero(tmp_path): result = subprocess.run( - [BASH, str(INSTALLER), "--help"], capture_output=True, text=True, + [BASH, bash_path(INSTALLER), "--help"], capture_output=True, text=True, check=False, input="", env=command_env(home=tmp_path, setup_sh=None, env={"GH_TOKEN": "bogus", diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 5e495477..2f159ce9 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -26,6 +26,7 @@ import os import re +import shlex import shutil import stat import subprocess @@ -34,7 +35,7 @@ import pytest -from conftest import REPO, WINDOWS_SKIP, git +from conftest import BASH, BASH_SKIP, REPO, bash_path, git, native_path COMMAND = REPO / "openRepoShape" SETUP = REPO / "setup.sh" @@ -74,14 +75,16 @@ #: about a different line. USAGE_SCAFFOLD = USAGE_LINES[3] -#: Same two reasons as `test_setup_sh.py`: no bash, or a Windows runner where -#: bash.exe exists and the `python3` the fetched `setup.sh` calls does not. -#: The command is a macOS/Linux/WSL2 convenience and has no Windows twin — -#: there the way in is `py setup-project.py`, which needs no command to fetch -#: it because it IS the file you downloaded. -pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, - reason="openRepoShape is a bash script"), - WINDOWS_SKIP] +#: Every run is `BASH`: the `bash` on PATH, or on Windows Git Bash, with the +#: PATH Git Bash makes (`git_bash_session`), every path spelled the way Git +#: Bash spells it (`bash_path`) and every script a test writes for it given +#: `\n` line ends (`write_script`) (#585). A `bash -s` run names `BASH` as +#: its `executable` and keeps `bash` as its argv[0], so `$0` is the word +#: `bash`, as a pipe into it gives. The suite wore `WINDOWS_SKIP` until +#: then, and the command its one-line install places was said to work in +#: Git Bash with nothing having run it there. PowerShell still has no twin: +#: there the way in is `py setup-project.py`, the file you downloaded. +pytestmark = [BASH_SKIP, pytest.mark.usefixtures("git_bash_session")] def command_env(home: Path | None = None, env: dict | None = None, @@ -102,7 +105,7 @@ def command_env(home: Path | None = None, env: dict | None = None, "OPENREPOSHAPE_RECORD_REF"): environ.pop(name, None) if setup_sh is not None: - environ["OPENREPOSHAPE_SETUP_SH"] = str(setup_sh) + environ["OPENREPOSHAPE_SETUP_SH"] = bash_path(setup_sh) environ.setdefault("GIT_AUTHOR_NAME", "openRepoShape tests") environ.setdefault("GIT_AUTHOR_EMAIL", "tests@openreposhape.invalid") environ.setdefault("GIT_COMMITTER_NAME", "openRepoShape tests") @@ -127,12 +130,45 @@ def run_cmd(*args: str, home: Path | None = None, env: dict | None = None, `command` is this checkout's file unless a test names an INSTALLED copy, which is how the record `--install` leaves beside one is read (#564). """ - return subprocess.run(["bash", str(command), *args], capture_output=True, - text=True, check=False, input="", - env=command_env(home, env, setup_sh), + return subprocess.run([BASH, bash_path(command), *args], + capture_output=True, text=True, check=False, + input="", env=command_env(home, env, setup_sh), start_new_session=True) +def write_script(path: Path, text: str) -> None: + """A script some bash will read, written with `\\n` line ends everywhere: + `write_text` writes `\\r\\n` on Windows (#585).""" + path.write_bytes(text.encode("utf-8")) + + +def assert_a_command(path: Path) -> None: + """`--install` placed a command: mode 755, or under Git Bash, which + keeps no mode bits (its drives are mounted `noacl`, so `chmod` changes + nothing there), one bash's own `-x` says it will run (#585).""" + if os.name != "nt": + assert stat.S_IMODE(path.stat().st_mode) == 0o755, path + return + probe = subprocess.run([BASH, "-c", 'test -x "$1"', "-", bash_path(path)], + capture_output=True, check=False) + assert probe.returncode == 0, f"bash will not run {path}" + + +def put_on_path(directory: Path, name: str, real: str) -> None: + """`directory/name` runs the program at `real`, for a `$PATH` a test + builds out of these and nothing else: a symlink, or on Windows a + `#!/bin/sh` script that execs it (#585). A symlink there needs an + elevated runner or Developer Mode, a copy of a Git Bash program cannot + find the DLL it loads from its own directory, and `/bin/sh` is found + through Git Bash's own mounts with nothing on `$PATH`.""" + link = directory / name + if os.name != "nt": + link.symlink_to(real) + return + write_script(link, "#!/bin/sh\nexec %s \"$@\"\n" + % shlex.quote(bash_path(real))) + + # --- what it says about itself --------------------------------------------- def test_help_prints_every_usage_line(): @@ -208,7 +244,7 @@ def test_install_writes_an_executable_copy(tmp_path): for name in INSTALLED: target = tmp_path / ".local" / "bin" / name assert target.is_file(), result.stdout + f" (missing {name})" - assert stat.S_IMODE(target.stat().st_mode) == 0o755, name + assert_a_command(target) assert target.read_bytes() == (REPO / name).read_bytes(), name assert f"{name}: installed at" in result.stdout assert f"openRepoShape: {len(INSTALLED)} of {len(INSTALLED)} placed" \ @@ -278,8 +314,9 @@ def test_install_points_at_openrepotools(tmp_path): log = tmp_path / "requests.log" served = fake_github(tmp_path, INSTALLED, log=log) result = subprocess.run( - ["bash", "-s", "--", "--install"], capture_output=True, text=True, - check=False, input=COMMAND.read_text(encoding="utf-8"), + ["bash", "-s", "--", "--install"], executable=BASH, + capture_output=True, text=True, check=False, + input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, env={**served, @@ -305,7 +342,8 @@ def test_install_points_at_openrepotools(tmp_path): def test_install_says_how_to_put_it_on_path(tmp_path): result = run_cmd("--install", home=tmp_path) - assert f'export PATH="{tmp_path}/.local/bin:$PATH"' in result.stdout + assert f'export PATH="{bash_path(tmp_path)}/.local/bin:$PATH"' \ + in result.stdout def test_install_from_a_file_never_calls_gh(tmp_path): @@ -326,12 +364,11 @@ def test_install_from_a_file_never_calls_gh(tmp_path): # QUOTED for the reason `fake_github`'s log is: an unquoted path with a # space in it makes the redirection land somewhere else, and a witness # nobody can find is a test that passes without guarding. - (shim / "gh").write_text( - f"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{log}'\nexit 1\n", - encoding="utf-8") + write_script(shim / "gh", + f"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{log}'\nexit 1\n") (shim / "gh").chmod(0o755) result = run_cmd("--install", home=tmp_path, - env={"PATH": f"{shim}:{os.environ['PATH']}"}) + env={"PATH": f"{shim}{os.pathsep}{os.environ['PATH']}"}) assert result.returncode == 0, result.stderr called = log.read_text(encoding="utf-8").split("\n")[:-1] \ if log.exists() else [] @@ -436,8 +473,9 @@ def test_a_bare_run_fetches_nothing(tmp_path): log = tmp_path / "calls.log" served = fake_github(tmp_path, INSTALLED, log=log) result = subprocess.run( - ["bash", str(COMMAND)], capture_output=True, text=True, check=False, - input="", env=command_env(home=tmp_path, setup_sh=None, env=served)) + [BASH, bash_path(COMMAND)], capture_output=True, text=True, + check=False, input="", + env=command_env(home=tmp_path, setup_sh=None, env=served)) assert result.returncode == 1, result.stdout + result.stderr assert USAGE_LINES[0] in result.stdout assert not log.exists(), ( @@ -497,24 +535,25 @@ def machine_path(tmp_path, present, logged_in=True) -> dict: for name in INSTALL_NEEDS: real = shutil.which(name) assert real, f"this machine has no {name}, which --install needs" - link = binaries / name - if not link.exists(): - link.symlink_to(real) + if not (binaries / name).exists(): + put_on_path(binaries, name, real) for name in present: script = binaries / name if name == "gh": - script.write_text( + write_script( + script, "#!/bin/sh\n" f"printf 'gh %s\\n' \"$*\" >> '{log}'\n" 'case "$*" in "auth status") exit ' f'{0 if logged_in else 1};; esac\n' - "exit 1\n", encoding="utf-8") + "exit 1\n") else: - script.write_text( + write_script( + script, "#!/bin/sh\n" f"printf '{name} %s\\n' \"$*\" >> '{log}'\n" f"printf 'fake {name} was EXECUTED by a read-only report\\n' >&2\n" - "exit 1\n", encoding="utf-8") + "exit 1\n") script.chmod(0o755) return {"PATH": str(binaries), "_log": str(log)} @@ -527,7 +566,7 @@ def install_with_machine(tmp_path, present, logged_in=True): # The bash by ABSOLUTE path: `subprocess` resolves a bare argv[0] against # the CHILD's PATH, and this one holds no bash on purpose. result = subprocess.run( - [shutil.which("bash"), str(COMMAND), "--install"], + [BASH, bash_path(COMMAND), "--install"], capture_output=True, text=True, check=False, input="", env=command_env(home=tmp_path, setup_sh=None, env={**env, "OPENREPOSHAPE_BIN_DIR": str(bin_dir)})) @@ -630,11 +669,11 @@ def test_a_ref_the_doctor_would_check_out_is_validated(ref, tmp_path): any clone does. """ result = subprocess.run( - ["bash", "-s", "--", "--doctor", str(tmp_path)], + ["bash", "-s", "--", "--doctor", str(tmp_path)], executable=BASH, capture_output=True, text=True, check=False, input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, - env={"OPENREPOSHAPE_REPO": str(REPO), + env={"OPENREPOSHAPE_REPO": bash_path(REPO), "OPENREPOSHAPE_REF": ref})) assert result.returncode == 2, result.stdout + result.stderr assert "OPENREPOSHAPE_REF" in result.stderr @@ -671,11 +710,11 @@ def test_a_checkout_without_the_doctor_refuses_by_name(tmp_path): subprocess.run(["git", "push", "-q", "origin", "main"], cwd=str(seed), capture_output=True, check=True) result = subprocess.run( - ["bash", "-s", "--", "--doctor", str(tmp_path)], + ["bash", "-s", "--", "--doctor", str(tmp_path)], executable=BASH, capture_output=True, text=True, check=False, input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, - env={"OPENREPOSHAPE_REPO": str(bare), + env={"OPENREPOSHAPE_REPO": bash_path(bare), "TMPDIR": str(temporary)})) assert result.returncode == 2, result.stdout + result.stderr assert "has no shape-doctor.py" in result.stderr @@ -813,7 +852,7 @@ def fake_github(tmp_path, served_names, log: Path | None = None) -> dict: """ served = tmp_path / "served" served.mkdir(exist_ok=True) - (served / "setup.sh").write_text(STUB_SETUP_SH, encoding="utf-8") + write_script(served / "setup.sh", STUB_SETUP_SH) for name in served_names: (served / name).write_bytes((REPO / name).read_bytes()) @@ -831,20 +870,22 @@ def logging(program): return "" if log is None else \ f'printf \'{program} %s\\n\' "$*" >> \'{log}\'\n' - (fake / "gh").write_text( + write_script( + fake / "gh", "#!/bin/sh\n" + logging("gh") + 'case "$*" in\n' + routes + "esac\n" 'printf \'fake gh: unexpected call: %s\\n\' "$*" >&2\n' - "exit 1\n", encoding="utf-8") + "exit 1\n") (fake / "gh").chmod(0o755) - (fake / "curl").write_text( + write_script( + fake / "curl", "#!/bin/sh\n" + logging("curl") + "printf 'fake curl: no test here may reach the network\\n' >&2\n" - "exit 1\n", encoding="utf-8") + "exit 1\n") (fake / "curl").chmod(0o755) return {"PATH": f"{fake}{os.pathsep}{os.environ['PATH']}"} @@ -884,7 +925,7 @@ def test_the_fetched_setup_sh_lands_in_a_workdir_that_still_exists(offline_githu + result.stdout + result.stderr) ran_in, forwarded = match.group(1), match.group(2).split() assert "--preflight" in forwarded, forwarded - assert not Path(ran_in).exists(), ( + assert not native_path(ran_in).exists(), ( f"{ran_in} outlived the command; the EXIT trap did not fire") @@ -915,8 +956,9 @@ def test_a_failed_fetch_replaces_nothing(tmp_path): encoding="utf-8") withheld = fake_github(tmp_path, []) result = subprocess.run( - ["bash", "-s", "--", "--install"], capture_output=True, text=True, - check=False, input=COMMAND.read_text(encoding="utf-8"), + ["bash", "-s", "--", "--install"], executable=BASH, + capture_output=True, text=True, check=False, + input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, env={**withheld, @@ -963,10 +1005,9 @@ def test_all_or_none_still_holds_at_more_than_one_installable(tmp_path): source = COMMAND.read_text(encoding="utf-8") assert "\nINSTALLABLES=(openRepoShape)\n" in source, ( "the array this test lengthens has moved or been reshaped") - shim.write_text( - source.replace("\nINSTALLABLES=(openRepoShape)\n", - "\nINSTALLABLES=(openRepoShape witness)\n", 1), - encoding="utf-8") + write_script( + shim, source.replace("\nINSTALLABLES=(openRepoShape)\n", + "\nINSTALLABLES=(openRepoShape witness)\n", 1)) bin_dir = tmp_path / "bin" bin_dir.mkdir() @@ -974,8 +1015,8 @@ def test_all_or_none_still_holds_at_more_than_one_installable(tmp_path): encoding="utf-8") withheld = fake_github(tmp_path, ["openRepoShape"]) result = subprocess.run( - ["bash", str(shim), "--install"], capture_output=True, text=True, - check=False, input="", + [BASH, bash_path(shim), "--install"], capture_output=True, + text=True, check=False, input="", env=command_env(home=tmp_path, setup_sh=None, env={**withheld, "OPENREPOSHAPE_BIN_DIR": str(bin_dir)})) @@ -1007,8 +1048,9 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, """ bin_dir = tmp_path / "bin" result = subprocess.run( - ["bash", "-s", "--", "--install"], capture_output=True, text=True, - check=False, input=COMMAND.read_text(encoding="utf-8"), + ["bash", "-s", "--", "--install"], executable=BASH, + capture_output=True, text=True, check=False, + input=COMMAND.read_text(encoding="utf-8"), # cwd: `bash -s` leaves $BASH_SOURCE unset, so the command's $SELF is # the literal `bash`, and `[ -f "$SELF" ]` must be false for the # fetching branch to be the one under test. A directory holding @@ -1023,7 +1065,7 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, for name in INSTALLED: target = bin_dir / name assert target.is_file(), result.stdout + result.stderr + f" ({name})" - assert stat.S_IMODE(target.stat().st_mode) == 0o755, name + assert_a_command(target) assert target.read_bytes() == (REPO / name).read_bytes(), name assert f"{name}: installed at" in result.stdout @@ -1044,7 +1086,7 @@ def install_at(tmp_path, ref=None, command=COMMAND, opt_in=True): it (`$OPENREPOSHAPE_RECORD_REF=1`) unless `opt_in` is false; the result, and the path of the command it places.""" bin_dir = tmp_path / "bin" - env = {"OPENREPOSHAPE_BIN_DIR": str(bin_dir)} + env = {"OPENREPOSHAPE_BIN_DIR": bash_path(bin_dir)} if ref is not None: env["OPENREPOSHAPE_REF"] = ref if opt_in: @@ -1084,9 +1126,9 @@ def test_install_records_the_ref_beside_a_command_it_leaves_unchanged( assert installed.read_bytes() == COMMAND.read_bytes(), ( "the command must stay byte for byte the file it was installed from") assert result.stdout.splitlines()[:3] == [ - f"openRepoShape: installed at {installed}", - f"openRepoShape: pinned to {PINNED}, recorded in {record}", - f"openRepoShape: 1 of 1 placed in {installed.parent}"] + f"openRepoShape: installed at {bash_path(installed)}", + f"openRepoShape: pinned to {PINNED}, recorded in {bash_path(record)}", + f"openRepoShape: 1 of 1 placed in {bash_path(installed.parent)}"] assert version_of(installed) == \ f"openRepoShape (opensoft/openRepoShape @ {PINNED})" assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}) == \ @@ -1118,13 +1160,14 @@ def test_installing_again_keeps_moves_or_drops_the_record(tmp_path): same, installed = install_at(tmp_path, PINNED) record = record_of(installed) assert same.returncode == 0, same.stderr - assert f"openRepoShape: already pinned to {PINNED} in {record} " \ + assert f"openRepoShape: already pinned to {PINNED} in " \ + f"{bash_path(record)} " \ "(unchanged)" in same.stdout assert same.stdout.count("(unchanged)") == 2, same.stdout newer, _ = install_at(tmp_path, "v10.0.0") assert f"openRepoShape: pinned to v10.0.0 (was {PINNED}), recorded in " \ - f"{record}" in newer.stdout + f"{bash_path(record)}" in newer.stdout itself, _ = install_at(tmp_path, command=installed) assert "openRepoShape: already pinned to v10.0.0" in itself.stdout assert record.read_text(encoding="utf-8") == "v10.0.0\n" @@ -1166,7 +1209,7 @@ def test_a_record_that_is_not_a_ref_is_refused_by_name(tmp_path, text, shown): record_of(installed).write_bytes(text) result = run_cmd("--version", command=installed) assert result.returncode == 2, result.stdout + result.stderr - assert f"the ref recorded in {record_of(installed)} {shown}" \ + assert f"the ref recorded in {bash_path(record_of(installed))} {shown}" \ in result.stderr assert "Run the installer again to record a release, or delete that " \ "file to follow main." in result.stderr @@ -1227,6 +1270,29 @@ def test_a_link_to_the_installed_command_reads_its_record(tmp_path, links): f"openRepoShape (opensoft/openRepoShape @ {PINNED})" +@pytest.mark.parametrize("msys", [None, "winsymlinks:nativestrict"], + ids=["ln-default", "ln-nativestrict"]) +def test_a_link_made_with_ln_s_reads_the_record_and_a_copy_follows_main( + tmp_path, msys): + """`ln -s` IN GIT BASH MAKES A COPY unless `$MSYS` says + `winsymlinks:nativestrict` (#585), which makes a Windows symlink. A copy + has no record beside it, so it follows main and `--version` says so; + a link, made anywhere else or with that setting, reads the record of + the command it leads to. `$MSYS` means nothing off Windows.""" + _result, installed = install_at(tmp_path, PINNED) + link = tmp_path / "ors" + env = dict(os.environ, **({"MSYS": msys} if msys else {})) + made = subprocess.run([BASH, "-c", 'ln -s "$1" "$2"', "-", + bash_path(installed), bash_path(link)], + capture_output=True, text=True, check=False, + env=env) + assert made.returncode == 0, made.stderr + copied = os.name == "nt" and msys is None + assert link.is_symlink() is not copied + assert version_of(link) == "openRepoShape (opensoft/openRepoShape @ " \ + f"{'main' if copied else PINNED})" + + def test_a_record_that_is_a_directory_is_refused_in_the_usual_form(tmp_path): """Not a raw bash error, by every run that reads it and by an install that would write or remove it, which places nothing.""" @@ -1237,7 +1303,8 @@ def test_a_record_that_is_a_directory_is_refused_in_the_usual_form(tmp_path): for result in (run_cmd("--version", command=installed), install_at(tmp_path, PINNED)[0]): assert result.returncode == 2, result.stdout + result.stderr - assert result.stderr.startswith(f"\nREFUSED: {record} is not a file") + assert result.stderr.startswith( + f"\nREFUSED: {bash_path(record)} is not a file") assert "Is a directory" not in result.stderr assert installed.read_bytes() == COMMAND.read_bytes() @@ -1260,7 +1327,7 @@ def test_setup_sh_clones_the_standard_at_the_ref_in_force( clones the standard at the same ref, not at main.""" _result, installed = install_at(tmp_path, recorded) stub = tmp_path / "setup.sh" - stub.write_text(STUB_SETUP_SH, encoding="utf-8") + write_script(stub, STUB_SETUP_SH) result = run_cmd("--preflight", command=installed, setup_sh=stub, env=None if variable is None else {"OPENREPOSHAPE_REF": variable}) @@ -1312,7 +1379,7 @@ def test_the_doctor_checks_its_clone_out_at_the_ref_in_force( _result, installed = install_at(tmp_path, recorded) temporary = tmp_path / "tmp" temporary.mkdir() - env = {"OPENREPOSHAPE_REPO": str(standard_at_three_refs(tmp_path)), + env = {"OPENREPOSHAPE_REPO": bash_path(standard_at_three_refs(tmp_path)), "TMPDIR": str(temporary)} if variable is not None: env["OPENREPOSHAPE_REF"] = variable From 01e8c2c0f8f65bb1cb160396025aac485b65d8b9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 16:23:45 +0000 Subject: [PATCH 11/14] README.md and docs/handbook.html: Git Bash runs the one-line install and the openRepoShape command, tested in CI but for a typed `yes`, which the preflight the one-line install ends with cannot take there, so README and the handbook no longer call that flow supported as a whole; the tests anchor two Windows-only paths and drop an inherited `MSYS` (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review on #596 (rv585, S1): under `curl ... | bash`, install.sh runs `exec --- .github/workflows/tests.yml | 8 +++++--- README.md | 8 ++++---- docs/handbook.html | 23 +++++++++++++---------- tests/conftest.py | 7 +++++-- tests/test_openreposhape_command.py | 14 ++++++++++++-- 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4f16121..4c0fc210 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -196,9 +196,11 @@ jobs: # GIT BASH, OR THIS JOB FAILS HERE (#585). The suites of `openRepoShape` # and `install.sh` run under Git for Windows' own bash, which - # `tests/conftest.py` finds beside the git on PATH (`BASH`), never as - # the `bash` PATH names first: on this image that can be System32's - # WSL launcher. Where it finds none they SKIP, and a skip here would + # `tests/conftest.py` finds beside the git on PATH (`BASH`), never by + # name: a bare `bash` runs System32's WSL launcher, which CreateProcess + # searches before PATH, and the `bash` PATH names first on this image, + # `Git\bin\bash.exe`, puts Git's real `curl` ahead of the suites' + # fake. Where it finds none they SKIP, and a skip here would # make README's Git Bash sentence a claim again, so this step refuses # first and prints the bash the suite will run. - name: Git Bash is the bash the launcher and installer suites run diff --git a/README.md b/README.md index ffb82387..a3138e74 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,8 @@ preflight, the same naming check, the same plan, the same one `yes`, the same self-bootstrap outside a checkout. It never needs `make` and never looks for a `python3` — it runs the interpreter that started it. That two-liner IS the Windows way in from PowerShell, which has no twin of the `openRepoShape` -command in step 3 below. In Git Bash, the bash that Git for Windows installs, -the one-line install and that command are supported, and CI tests them there. +command in step 3 below. Git Bash runs that command and step 3's one line, +tested in CI but for a typed `yes`, which its preflight cannot take there. @@ -237,8 +237,8 @@ and no login. Its command is PINNED to the release tag, recorded in tag, not at main. It installs `git-filter-repo` with `pipx` where it can, or prints the command, and ends with `openRepoShape --preflight`. `install.sh` itself never runs `sudo`; the preflight it ends with offers what is missing, -each behind a typed `yes` at your terminal. `| bash -s -- v1.0.1` names a -release; the same line again upgrades. +each behind a typed `yes` at your terminal; in Git Bash it can take none. +`| bash -s -- v1.0.1` names a release; the same line again upgrades. By hand, the same command at main, which records no ref and follows main: diff --git a/docs/handbook.html b/docs/handbook.html index 4d779f5a..c0b4d6fa 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -609,7 +609,7 @@

    The one thing to read before electing it

    - +

    Setup

    Start here

    @@ -721,8 +721,10 @@

    The tool offers the rest

    py setup-project.py --preflight, winget install --id Git.Git -e for git: a run refuses a missing git before its preflight starts. There is no openRepoShape command to install in PowerShell: that two-liner - IS the Windows way in there. In Git Bash, the bash that Git for Windows installs, the - one-line install and the openRepoShape command are supported, and CI tests them there.

    + IS the Windows way in there. Git Bash, the bash that Git for Windows installs, runs the + openRepoShape command and the one-line install, and CI tests both there, but + for a typed yes: CI has no terminal, and in Git Bash the preflight that the + one-line install ends with cannot take one, so none of its offers can be accepted there.

    @@ -965,7 +967,8 @@

    Consent rules, on every platform

    yes, at the same prompt: Type yes to continue:. install.sh asks nothing before its own two acts, because running it is your act: it places the command and installs git-filter-repo with pipx where it can, and itself - never runs sudo. The preflight it ends with asks before each offer, as above. + never runs sudo. The preflight it ends with asks before each offer, as above; + in Git Bash it can take no yes, so it installs nothing.
  • --yes skips the scaffold question only. It does not answer an install offer, gh auth login or gh auth setup-git.
  • With no terminal attached there is no offer at all.
  • @@ -1037,7 +1040,7 @@

    What you have afterwards — the same on every platform

    - +

    Setup

    If it fails part-way

    @@ -1072,7 +1075,7 @@

    If it fails part-way

    - + @@ -1130,7 +1133,7 @@

    Reading private legs in CI

    - + @@ -1366,7 +1369,7 @@

    The InkRouter example

    - + @@ -1663,7 +1666,7 @@

    Is this repository compliant? openRepoShape --doctor

    - + diff --git a/tests/conftest.py b/tests/conftest.py index 8365cda3..f55f9cf0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -310,10 +310,13 @@ def git_bash_session(monkeypatch): """ if GIT_FOR_WINDOWS is None: return + # Git for Windows on ARM64 ships `clangarm64` where x64 has `mingw64`. + msystem = "clangarm64" if (GIT_FOR_WINDOWS / "clangarm64").is_dir() \ + else "mingw64" monkeypatch.setenv("PATH", os.pathsep.join([ - str(GIT_FOR_WINDOWS / "mingw64" / "bin"), + str(GIT_FOR_WINDOWS / msystem / "bin"), str(GIT_FOR_WINDOWS / "usr" / "bin"), os.environ.get("PATH", "")])) - monkeypatch.setenv("MSYSTEM", "MINGW64") + monkeypatch.setenv("MSYSTEM", msystem.upper()) def rmtree(path: Path) -> None: diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 2f159ce9..875ab9e0 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -925,6 +925,10 @@ def test_the_fetched_setup_sh_lands_in_a_workdir_that_still_exists(offline_githu + result.stdout + result.stderr) ran_in, forwarded = match.group(1), match.group(2).split() assert "--preflight" in forwarded, forwarded + # The directory it was made in is still there, so the line after this + # one reads a path this interpreter can open, and cannot pass on one it + # cannot: `/tmp/...` read as a Windows path names no directory at all. + assert native_path(ran_in).parent.is_dir(), ran_in assert not native_path(ran_in).exists(), ( f"{ran_in} outlived the command; the EXIT trap did not fire") @@ -1278,10 +1282,16 @@ def test_a_link_made_with_ln_s_reads_the_record_and_a_copy_follows_main( `winsymlinks:nativestrict` (#585), which makes a Windows symlink. A copy has no record beside it, so it follows main and `--version` says so; a link, made anywhere else or with that setting, reads the record of - the command it leads to. `$MSYS` means nothing off Windows.""" + the command it leads to. `$MSYS` means nothing off Windows. A `$MSYS` + of the caller's own is dropped first, so the default is the default; + the setting, like every symlink test here, needs Windows' right to make + a symlink (Developer Mode, or an elevated shell).""" _result, installed = install_at(tmp_path, PINNED) link = tmp_path / "ors" - env = dict(os.environ, **({"MSYS": msys} if msys else {})) + env = {name: value for name, value in os.environ.items() + if name != "MSYS"} + if msys: + env["MSYS"] = msys made = subprocess.run([BASH, "-c", 'ln -s "$1" "$2"', "-", bash_path(installed), bash_path(link)], capture_output=True, text=True, check=False, From 3095a9c097f108a9129c5c1c2c2133c3ffc14aee Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 16:49:25 +0000 Subject: [PATCH 12/14] tests/test_openreposhape_command.py: the fetched-setup.sh test asserts on one native path, so its parent-directory anchor also pins the line that says the directory is gone (#585) The two asserts now share `ran_dir`, so reverting `native_path` for the existence check alone no longer passes beside a still-true anchor. This answers the re-review's nit 1. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_openreposhape_command.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 875ab9e0..a91e3409 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -925,11 +925,12 @@ def test_the_fetched_setup_sh_lands_in_a_workdir_that_still_exists(offline_githu + result.stdout + result.stderr) ran_in, forwarded = match.group(1), match.group(2).split() assert "--preflight" in forwarded, forwarded + ran_dir = native_path(ran_in) # The directory it was made in is still there, so the line after this # one reads a path this interpreter can open, and cannot pass on one it # cannot: `/tmp/...` read as a Windows path names no directory at all. - assert native_path(ran_in).parent.is_dir(), ran_in - assert not native_path(ran_in).exists(), ( + assert ran_dir.parent.is_dir(), ran_in + assert not ran_dir.exists(), ( f"{ran_in} outlived the command; the EXIT trap did not fire") From 5f001de1cb621e7674a94853641e4d401de6e006 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 18:33:27 +0000 Subject: [PATCH 13/14] .github/workflows/tests.yml, README.md and docs/handbook.html: a small `tests-git-bash` job runs the launcher's and the installer's suites on windows-latest under Git Bash, on every trigger, as the one exception to #597 on Brett Heap's word, so README's Git Bash sentence is tested in CI again and the macOS sentence no longer says CI runs on Linux only (#585) #597 (main 654c476c) dropped the Windows and macOS jobs for the whole estate, and the merge before this commit took that workflow. That left README's "Git Bash runs that command and step 3's one line, tested in CI but for a typed `yes`" with nothing behind it. Brett Heap, 2026-10-15T18:20:35Z: "Option 1: keep a small Git Bash job for #596". The option he picked: a small Windows job for just these two suites, which finishes inside the Linux job's time and keeps README's claim true, as an exception to #597's decision. The workflow: - The header keeps #597's text and adds one sentence naming the exception, with his word and its time. - `tests-git-bash`: windows-latest, `timeout-minutes: 15`, no `if:`, so it runs on a push to main, on pull requests and on a manual run. A squash lands a tree no pull-request run tested (#122), and these two suites are the only run of the one-line install on Windows. - Its steps: checkout and setup-python at the ubuntu job's two commits (`fetch-depth: 0`, because the launcher's suite clones this checkout itself in test_a_ref_the_doctor_would_check_out_is_validated); pytest 8.3.4; the git identity, appended to GITHUB_ENV in PowerShell as the old Windows job did; the guard step "Git Bash is the bash the launcher and installer suites run", which fails the job when conftest finds no Git Bash; then `python -m pytest -q -rA tests/test_openreposhape_command.py tests/test_install_sh.py`, with the old job's `--basetemp` under `runner.temp` for MAX_PATH. - Its env is the old Windows job's: PYTHONUTF8=1 (the suites decode bash's output with `text=True`, and the launcher's `--help` prints em dashes), core.autocrlf=false and core.longpaths=true. Every Windows run of these two suites, #585's four probes included, had all three. - Left out: git-filter-repo, because these suites never reach a real one (the install suite fakes it and pipx; the launcher's machine report gets a fake on the PATH it builds; on Linux the two suites pass with no git-filter-repo on PATH); and actionlint, which the ubuntu job runs on this file. tests/test_install_sh.py's pin check still finds the ubuntu job's git-filter-repo pin. The docs. README's macOS sentence and the handbook's macOS panel said "CI runs on Linux only and no run of it checks them against bash 3.2". They now say "no CI run, on Linux or in Git Bash on Windows, checks them against bash 3.2", which is true with this job and keeps README at 1449 lines, its cap. README's Windows paragraph and the handbook's Windows panel already say CI tests Git Bash, and are true again. The seven handbook markers for README.md section "Starting a project in a new organisation" record its new hash, 34723d9ae3d5e64cde648576855ce706589ffc0d. actionlint 1.7.12 on tests.yml and the two template workflows: exit 0. Linux, before this commit: tests/test_repo_hygiene.py, tests/test_readme_sections.py, tests/test_cli_reference.py and tests/test_setup_by_hand_commands.py, 168 passed; the two suites, 113 passed, and 113 passed again with no git-filter-repo on PATH. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 95 +++++++++++++++++++++++++++++++++++++ README.md | 6 +-- docs/handbook.html | 18 +++---- 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b2de8af7..8eb1c644 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,10 @@ name: tests # The suite runs on LINUX ONLY, on every trigger: a push to `main`, a pull # request and a manual run all use the one ubuntu job below. Brett Heap decided # on 2026-10-15, for the whole estate, to drop the macOS and Windows test jobs. +# One exception, on Brett Heap's word at 2026-10-15T18:20:35Z, +# "Option 1: keep a small Git Bash job for #596": the `tests-git-bash` job +# below runs the launcher's and the installer's suites, and nothing else, on +# Windows under Git Bash. on: push: @@ -111,3 +115,94 @@ jobs: .github/workflows/tests.yml templates/assembly-root/.github/workflows/validate.yml templates/family-root/.github/workflows/validate.yml + + # THE LAUNCHER'S AND THE INSTALLER'S SUITES ON WINDOWS, UNDER GIT BASH + # (#585). README says Git Bash runs the `openRepoShape` command and the + # one-line install, "tested in CI but for a typed `yes`", and this job is + # what makes that true: `tests/test_openreposhape_command.py` and + # `tests/test_install_sh.py`, run here as Git Bash runs them. + # + # TWO SUITES, NOT THE WHOLE SUITE. #597 dropped the Windows job for the + # whole estate, and this job is the one exception made to it (see the + # header), so it runs what README's sentence claims and nothing more: + # about three minutes, beside the ubuntu job and not after it. No + # git-filter-repo, because these suites never reach a real one (the install + # suite fakes it and pipx, and the launcher's machine report gets a fake on + # the PATH it builds), and no actionlint, which the ubuntu job runs on this + # file. + # + # THE BASH IS GIT'S OWN `usr\bin\bash.exe`, which `tests/conftest.py` + # (`BASH`) finds from `git --exec-path` and never by name: a bare `bash` + # runs System32's WSL launcher, which CreateProcess searches before PATH, + # and `Git\bin\bash.exe`, the `bash` PATH names first on this image, puts + # Git's real `curl` ahead of the suites' fake. conftest's comment there + # has the rest. + # + # ON EVERY TRIGGER, a push to `main` included: a squash lands a tree no + # pull-request run tested (#122), and these two suites are the only run + # of the one-line install on Windows. + tests-git-bash: + runs-on: windows-latest + timeout-minutes: 15 + env: + # The old Windows job's settings, which every Windows run of these + # two suites has had, #585's probes included. Per-invocation git + # config, never `git config --global`. + # + # PYTHONUTF8=1 (PEP 540): the suites read bash's output with + # `text=True`, which decodes in the locale's code page (cp1252 here) + # outside UTF-8 mode, and the launcher's `--help` is full of em dashes. + # + # core.autocrlf=false: Git for Windows' own default is `true`, and the + # suites commit, clone and check out repositories of their own; no test + # here asks for line-ending translation. + # + # core.longpaths=true: MAX_PATH is 260 characters, and the suites' + # clones nest under pytest's temp root. + PYTHONUTF8: '1' + GIT_CONFIG_COUNT: '2' + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: 'false' + GIT_CONFIG_KEY_1: core.longpaths + GIT_CONFIG_VALUE_1: 'true' + steps: + # The SAME two commits the ubuntu job pins. `fetch-depth: 0` because + # the launcher's suite clones this checkout itself + # (`OPENREPOSHAPE_REPO` in `test_a_ref_the_doctor_would_check_out_is_ + # validated`), and every Windows run of it has cloned a full one. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + # `python`, not `python3`: a Windows install has no `python3`. + - name: install the test runner + run: python -m pip install --disable-pip-version-check pytest==8.3.4 + + # Per-invocation identity, never `git config --global`. PowerShell is + # the default shell on this runner, so the append is + # `>> $env:GITHUB_ENV` rather than the ubuntu job's + # `{ echo ... } >> "$GITHUB_ENV"` block. + - name: git identity for the test repositories (env, not global config) + run: | + "GIT_AUTHOR_NAME=openRepoShape CI" >> $env:GITHUB_ENV + "GIT_AUTHOR_EMAIL=ci@openreposhape.invalid" >> $env:GITHUB_ENV + "GIT_COMMITTER_NAME=openRepoShape CI" >> $env:GITHUB_ENV + "GIT_COMMITTER_EMAIL=ci@openreposhape.invalid" >> $env:GITHUB_ENV + + # GIT BASH, OR THIS JOB FAILS HERE. Where conftest finds no Git Bash, + # both suites SKIP, and a job of skips would leave README's sentence a + # claim again, so this step refuses first and prints the bash the + # suites will run. + - name: Git Bash is the bash the launcher and installer suites run + run: python -c "import sys; sys.path.insert(0, 'tests'); from conftest import BASH; print(BASH); sys.exit(BASH is None)" + + # `-rA` names every test and its result in the log. `--basetemp` puts + # pytest's temp root as near the drive root as a path the runner + # guarantees allows: `runner.temp` is `D:\a\_temp` or `C:\a\_temp`, + # and the default profile path is ~50 of MAX_PATH's 260 characters + # before the suites' own nesting starts. + - name: the launcher's and the installer's suites, under Git Bash + run: python -m pytest -q -rA tests/test_openreposhape_command.py tests/test_install_sh.py --basetemp=${{ runner.temp }}\t diff --git a/README.md b/README.md index cc2bfb9f..2f4b7851 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ puts it there — run that line, or the next shell cannot find `brew`. Then `bre install gh`, and `brew install git-filter-repo` only if you are ADOPTING an existing repository rather than scaffolding a new one. The stock `/bin/bash` is 3.2 and is enough: `setup.sh`, `install.sh` and the `openRepoShape` command are -written to use no syntax newer than that, though CI runs on Linux only and no -run of it checks them against bash 3.2. That the login shell is zsh changes -nothing — all three entry points are run under `bash` explicitly. +written to use no syntax newer than that, though no CI run, on Linux or in Git +Bash on Windows, checks them against bash 3.2. That the login shell is zsh +changes nothing — all three entry points are run under `bash` explicitly. diff --git a/docs/handbook.html b/docs/handbook.html index e41ec6e1..c71d6d11 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -609,7 +609,7 @@

    The one thing to read before electing it

    - +

    Setup

    Start here

    @@ -689,8 +689,8 @@

    What this machine needs

    repository rather than scaffolding a new one.

    The stock /bin/bash is 3.2 and is enough: setup.sh, install.sh and the openRepoShape command are written to use no - syntax newer than that, though CI runs on Linux only and no run of it checks them against - bash 3.2. That the login shell is zsh changes nothing — all three entry points are + syntax newer than that, though no CI run, on Linux or in Git Bash on Windows, checks them + against bash 3.2. That the login shell is zsh changes nothing — all three entry points are run under bash explicitly.

    The tool offers the rest

    The preflight names what is missing and, for gh, its login and its @@ -1040,7 +1040,7 @@

    What you have afterwards — the same on every platform

    - +

    Setup

    If it fails part-way

    @@ -1075,7 +1075,7 @@

    If it fails part-way

    - + @@ -1133,7 +1133,7 @@

    Reading private legs in CI

    - + @@ -1369,7 +1369,7 @@

    The InkRouter example

    - + @@ -1666,7 +1666,7 @@

    Is this repository compliant? openRepoShape --doctor

    - + From fe58d661bf2368f1ec26a330f8fc6e6470a8c32b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 19:02:51 +0000 Subject: [PATCH 14/14] .github/workflows/tests.yml: the header says the FULL suite runs on Linux only, and the `tests-git-bash` comment gives its measured minute and a half instead of three minutes (#585) Review rv585c's N1: read alone, the header's first sentence said no suite runs off Linux now that `tests-git-bash` exists, so it says the FULL suite does, and the exception sentence after it is unchanged. N3: the job's comment said "about three minutes", the probes' figure, and the PR's own first run measured 86 s, 50 s of it pytest, so it says a minute and a half; comments only. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/tests.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8eb1c644..86841d68 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ name: tests # clones them over the filesystem. It never calls `gh`, never touches the # network, and must never create a real repository. # -# The suite runs on LINUX ONLY, on every trigger: a push to `main`, a pull +# The FULL suite runs on LINUX ONLY, on every trigger: a push to `main`, a pull # request and a manual run all use the one ubuntu job below. Brett Heap decided # on 2026-10-15, for the whole estate, to drop the macOS and Windows test jobs. # One exception, on Brett Heap's word at 2026-10-15T18:20:35Z, @@ -125,11 +125,11 @@ jobs: # TWO SUITES, NOT THE WHOLE SUITE. #597 dropped the Windows job for the # whole estate, and this job is the one exception made to it (see the # header), so it runs what README's sentence claims and nothing more: - # about three minutes, beside the ubuntu job and not after it. No - # git-filter-repo, because these suites never reach a real one (the install - # suite fakes it and pipx, and the launcher's machine report gets a fake on - # the PATH it builds), and no actionlint, which the ubuntu job runs on this - # file. + # about a minute and a half (86 s on the first run, 50 s of it pytest), + # beside the ubuntu job and not after it. No git-filter-repo, because + # these suites never reach a real one (the install suite fakes it and + # pipx, and the launcher's machine report gets a fake on the PATH it + # builds), and no actionlint, which the ubuntu job runs on this file. # # THE BASH IS GIT'S OWN `usr\bin\bash.exe`, which `tests/conftest.py` # (`BASH`) finds from `git --exec-path` and never by name: a bare `bash`