From 026280cdcb1694d844a731a3769a0adb45b22587 Mon Sep 17 00:00:00 2001
From: Brett Heap openRepoShape
convention, as data and as running code.
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.
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 --installSetup
openRepoShape --doctorSetup
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.
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.Setup
openRepoShape --doctorSetup
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 preflight names what is missing and, for gh, its login and its
@@ -1040,7 +1040,7 @@
Setup
openRepoShape --doctor