Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
026280c
openRepoShape: `--install` records the ref it runs at in `openRepoSha…
Oct 14, 2026
d0cdbf4
install.sh: one line, `curl -fsSL https://github.com/opensoft/openRep…
Oct 14, 2026
319b005
openRepoShape and install.sh, the fix round on #577: `--install` reco…
Oct 15, 2026
322a305
README.md, docs/handbook.html, AGENTS.md and the v1.0.1 release notes…
Oct 15, 2026
868c9bf
docs/release-notes/v1.0.1.md: the "Before tagging" steps are safe to …
Oct 15, 2026
892a041
Merge remote-tracking branch 'origin/l3-564-one-line-install' into l3…
Oct 15, 2026
b2f0be3
tests/readme-cap.d/564.txt: README.md's cap is 1449, raised by 22 for…
Oct 15, 2026
68b999f
docs/handbook.html: the consent rules say every action the tool OFFER…
Oct 15, 2026
53416db
Merge remote-tracking branch 'origin/main' into l3-564-carry-to-main
Oct 15, 2026
a155945
Merge remote-tracking branch 'origin/main' into l3-564-carry-to-main
Oct 15, 2026
c1e6b82
README.md: the NOTE says the preflight installs nothing without the t…
Oct 15, 2026
5191f0e
docs/handbook.html: the consent rules say install.sh asks nothing bef…
Oct 15, 2026
ba83231
openRepoShape: --install refuses a target that is a symbolic link, su…
Oct 15, 2026
8cae45c
install.sh: the one-line installer refuses a target that is a symboli…
Oct 15, 2026
604fbda
pyproject.toml, src/openreposhape/: the openreposhape package gives o…
Oct 15, 2026
4759eea
.github/workflows/tests.yml: a package job builds the openreposhape w…
Oct 15, 2026
ab8bfe3
tests/test_openreposhape_command.py: the symlink-target test asserts …
Oct 15, 2026
bfd927a
install.sh, tests/test_install_sh.py: the symlink-target check uses `…
Oct 15, 2026
cb910c4
src/openreposhape/, tests/test_openreposhape_package.py: the argument…
Oct 15, 2026
9b0a29e
.github/workflows/tests.yml: the package job runs pipx 1.11.1 on the …
Oct 15, 2026
d53080a
.github/workflows/tests.yml: the package job installs pipx from wheel…
Oct 15, 2026
25b2aee
Merge branch 'main' into l3-564-carry-to-main
brettheap Oct 15, 2026
a20c469
Merge remote-tracking branch 'origin/l3-564-carry-to-main' into l3-56…
Oct 15, 2026
22ade8e
openRepoShape: the comment over `--install`'s record says the target …
Oct 15, 2026
66bb2c7
src/openreposhape/, pyproject.toml, tests/test_openreposhape_package.…
Oct 15, 2026
6179bd7
tests/test_repo_hygiene.py: every module of src/openreposhape/ is pur…
Oct 15, 2026
d090f0c
src/openreposhape/standard.py, tests/test_openreposhape_package.py: a…
Oct 15, 2026
b23b245
Merge remote-tracking branch 'origin/main' into l3-564-carry-to-main
Oct 15, 2026
d5011b1
Merge branch 'main' into l3-564-carry-to-main
brettheap Oct 15, 2026
f9b07c9
Merge remote-tracking branch 'origin/main' into l3-564-carry-to-main
Oct 15, 2026
3d8fa05
Merge branch 'main' into l3-564-carry-to-main
brettheap Oct 15, 2026
06dad85
Merge #587's final head (3d8fa05e) into l3-563-pypi-package
Oct 15, 2026
3c208e9
Merge origin/main (22eaceed, #587's squash) into l3-563-pypi-package
Oct 15, 2026
1c8a708
.github/workflows/tests.yml, tests/test_openreposhape_package.py: the…
Oct 15, 2026
995e138
Merge branch 'main' into l3-563-pypi-package
brettheap Oct 15, 2026
cfbe59d
Merge origin/main (4cf5fb07, with #596's tests-git-bash job) into l3-…
Oct 16, 2026
4410da0
tests/test_install_sh.py, tests/test_openreposhape_command.py: the tw…
Oct 16, 2026
70955e4
.github/workflows/tests.yml: the header says what is the `package` jo…
Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ name: tests
# "Option 1: keep a small Git Bash job for #596": the `tests-git-bash` job
# below runs the launcher's and the installer's suites, and nothing else, on
# Windows under Git Bash.
#
# The `package` job at the end runs no pytest. It builds the openreposhape
# wheel and installs it with pipx, so what it checks needs PyPI (the build
# backend, git-filter-repo; #563). The other jobs take only test tools there.

on:
push:
Expand Down Expand Up @@ -218,3 +222,139 @@ jobs:
# before the suites' own nesting starts.
- name: the launcher's and the installer's suites, under Git Bash
run: python -m pytest -q -rA tests/test_openreposhape_command.py tests/test_install_sh.py --basetemp=${{ runner.temp }}\t

# THE openreposhape PACKAGE, BUILT AND INSTALLED AS A PERSON INSTALLS IT
# (#563). A JOB OF ITS OWN, AND NO PYTEST IN IT: the build backend
# `pyproject.toml` pins (hatchling) and git-filter-repo, the package's one
# dependency, both come from PyPI, and the suite in the `tests` job above
# never touches the network. What can be checked offline is checked there,
# on Linux like the rest of the suite since #597
# (`tests/test_openreposhape_package.py`); this job is what only a real
# build and a real pipx can say.
#
# UBUNTU, ON THE OLDEST PYTHON THE PACKAGE SUPPORTS, 3.9. The wheel is pure
# Python, one file for every system; the release workflow (#563's next
# pull request) installs it on Linux, macOS and Windows before it uploads
# anything.
package:
runs-on: ubuntu-latest
steps:
# The same two commits the other jobs pin.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.9'

# TWICE, AT THE COMMIT'S OWN TIME, AND THE TWO MUST BE ONE FILE: a
# release uploads bytes it can build again. `--no-deps`, because the
# wheel is this package alone; git-filter-repo arrives with the install.
- name: build the wheel twice, and compare the two
run: |
set -euo pipefail
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct HEAD)"
export SOURCE_DATE_EPOCH
for out in a b; do
python -m pip wheel --disable-pip-version-check --no-deps \
--wheel-dir "$RUNNER_TEMP/dist-$out" .
done
cmp "$RUNNER_TEMP"/dist-a/*.whl "$RUNNER_TEMP"/dist-b/*.whl
sha256sum "$RUNNER_TEMP"/dist-a/*.whl

# EVERY FILE IN THE WHEEL BUT ITS METADATA IS THIS COMMIT'S OWN, BYTE
# FOR BYTE, read from git: the package's modules from `src/`, and
# `setup-project.py`, which the wheel carries so that `--preflight`
# runs before git is installed.
- name: the wheel holds this commit's files, byte for byte
run: |
python - "$RUNNER_TEMP"/dist-a/*.whl <<'EOF'
import subprocess, sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as wheel:
for name in wheel.namelist():
if ".dist-info/" in name:
continue
source = ("setup-project.py"
if name == "openreposhape/setup-project.py"
else "src/" + name)
committed = subprocess.run(
["git", "show", "HEAD:" + source],
capture_output=True, check=True).stdout
if wheel.read(name) != committed:
sys.exit(f"{name} in the wheel is not {source} at HEAD")
print(f"{name} = HEAD:{source}")
EOF

# pipx AS A PERSON RUNS IT, into directories of this job's own, and ON
# THE 3.9 ABOVE. Not the runner's own pipx with `--python`: that pipx
# runs on the image's Python and shares ONE pip with every environment
# it makes, a pip that no longer runs on 3.9 (`dataclass(slots=True)`,
# this job's first run, 2026-10-15). So pipx itself runs on 3.9, at
# 1.11.1, the newest release that installs there, and its shared pip
# is then one that 3.9 runs. Wheels only (`--only-binary :all:`), so
# installing pipx runs no package's setup script.
- name: install the wheel with pipx
run: |
set -euo pipefail
{
echo "PIPX_HOME=$RUNNER_TEMP/pipx/home"
echo "PIPX_BIN_DIR=$RUNNER_TEMP/pipx/bin"
} >> "$GITHUB_ENV"
export PIPX_HOME="$RUNNER_TEMP/pipx/home"
export PIPX_BIN_DIR="$RUNNER_TEMP/pipx/bin"
python -m pip install --disable-pip-version-check \
--only-binary :all: pipx==1.11.1
python -m pipx --version
python -m pipx install "$RUNNER_TEMP"/dist-a/*.whl
"$PIPX_HOME/venvs/openreposhape/bin/python" --version
ls -l "$PIPX_BIN_DIR"

# THE FIVE COMMANDS PIPX PLACED, AGAINST THIS COMMIT. A bare repository
# on disk holds it as `main`, which an unstamped build follows, so
# nothing is cloned from github.com. No git-filter-repo is on PATH, and
# the doctor's machine row must still find the one pipx installed
# beside the package: the PATH every command gives its children.
- name: run the commands pipx placed
env:
OPENREPOSHAPE_REPO: ${{ runner.temp }}/standard.git
run: |
set -euo pipefail
git init -q --bare --initial-branch=main "$OPENREPOSHAPE_REPO"
git push -q "$OPENREPOSHAPE_REPO" HEAD:refs/heads/main
export PATH="$PIPX_BIN_DIR:$PATH"
if command -v git-filter-repo; then
echo "::error::git-filter-repo is on PATH already, so this job cannot show the commands finding their own"
exit 1
fi
out="$RUNNER_TEMP/out"
mkdir -p "$out"
openRepoShape --version | tee "$out/version"
grep -q '^openRepoShape 0.0.0 (.* @ main)$' "$out/version"
openRepoShape --help >/dev/null
status=0
openRepoShape --install 2>"$out/install" || status=$?
cat "$out/install"
test "$status" -eq 2
grep -q 'pipx upgrade openreposhape' "$out/install"
openRepoShape --preflight --local-remote-dir "$RUNNER_TEMP/no-remotes"
git init -q "$RUNNER_TEMP/empty"
status=0
openRepoShape --doctor "$RUNNER_TEMP/empty" --json \
>"$out/doctor.json" || status=$?
test "$status" -eq 2
python - "$out/doctor.json" \
"$PIPX_HOME/venvs/openreposhape/bin/git-filter-repo" <<'EOF'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
report = json.load(handle)
print("verdict:", report["verdict"])
[machine] = [row for row in report["rows"] if row["id"] == "machine"]
found = machine["detail"]["git-filter-repo"]
print("machine row: git-filter-repo at", found)
if found != sys.argv[2]:
sys.exit(f"the doctor found git-filter-repo at {found!r}, "
f"not at {sys.argv[2]!r}")
EOF
for tool in scaffold-project adopt-project update-shape shape-doctor; do
"$tool" --help >/dev/null
done
19 changes: 16 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
# 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.
# release found, a fetch that failed, a file that is not the command, or
# a command already there as a symbolic link (pipx's, #563).
# git-filter-repo never changes it: only adopting needs it, and
# `adopt-project.py` refuses with its own install command where it is missing.
# What it changes is the summary (#595): `adoption:` says whether it is there
Expand Down Expand Up @@ -361,6 +362,20 @@ main() {
command -v curl >/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"

# A LINK IS NOT A PLACE TO INSTALL TO (#563), and this installer says so
# itself, before anything is asked of the network. pipx places the
# openreposhape package's `openRepoShape` as a symbolic link in this same
# directory, and the command's `--install` copies with `cp`, which writes
# through a link into the package's own environment. The command refuses
# such a target too, but only from the release that added the check: the
# release named here may be older, so its refusal cannot be relied on.
bin_dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}"
target="$bin_dir/openRepoShape"
[[ ! -L "$target" ]] ||
die "$target is a symbolic link, so nothing was installed: pipx places the openreposhape package's openRepoShape that way, and installing this one through the link would overwrite the package's command. Use one installer or the other:
keep the package's command, and update it with: pipx upgrade openreposhape
or remove the link (pipx uninstall openreposhape, or rm $target), then run this line again."

# 1. THE RELEASE.
if [ -n "$version" ]; then
checked_version "$version" "the version"
Expand Down Expand Up @@ -404,8 +419,6 @@ main() {
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_RECORD_REF=1 openRepoShape --install"
OPENREPOSHAPE_REF="$version" OPENREPOSHAPE_RECORD_REF=1 \
"${BASH:-bash}" "$WORKDIR/openRepoShape" --install ||
Expand Down
19 changes: 18 additions & 1 deletion openRepoShape
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,24 @@ install_commands() {
{ [ "$REF_NAMED" -eq 1 ] && [ "${OPENREPOSHAPE_RECORD_REF:-}" = 1 ]; }; then
recording=1
fi
# THE RECORD SITS BESIDE THE FILE, symlinks followed, as it is read.
# A LINK IS NOT A PLACE TO COPY TO (#563). pipx places its
# `openRepoShape`, the openreposhape package's, as a symbolic link into
# the package's own environment, in this same `~/.local/bin`. `cp`
# writes THROUGH a link, so an install over one would overwrite the
# package's command with this file and leave the link where it was:
# two installers, one name, each breaking the other. Refused before
# anything is collected or placed, a dangling link included, and the
# refusal names both ways out. install.sh refuses the same target
# itself, for a release whose command predates this check.
for name in "${INSTALLABLES[@]}"; do
[ ! -L "$dir/$name" ] ||
die "$dir/$name is a symbolic link, so --install will not copy this command through it: pipx places the openreposhape package's openRepoShape that way, and a copy written through the link would overwrite the package's command. Nothing was placed. Use one installer or the other:
keep the package's command, and update it with: pipx upgrade openreposhape
or remove the link (pipx uninstall openreposhape, or rm $dir/$name), then run this again."
done
# THE RECORD SITS BESIDE THE FILE. The target is no link (refused just
# above), so `resolved` hands it back unchanged; the call stays because
# it is how `read_recorded_ref` names the record, and the two must agree.
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
Expand Down
83 changes: 83 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-License-Identifier: Apache-2.0
#
# THE openreposhape PACKAGE (#563): a SECOND WAY IN, NEVER A REQUIREMENT.
# Cloning this repository and running its tools with python3 and git alone
# keeps working, and every tool stays standard-library only
# (`tests/test_repo_hygiene.py` holds the package's own files to that too).
# Brett Heap approved the design on #563 at 2026-10-15T13:32:51Z ("approve
# all six for #563").
#
# THIN: the wheel holds `src/openreposhape/` as `openreposhape/`, plus ONE
# file of this tree byte for byte, `setup-project.py`, so that `openRepoShape
# --preflight` runs before git is installed. Every other run uses a full
# clone of this repository at the commit the package was built from
# (`src/openreposhape/standard.py`), so a pin means what it always meant.
#
# `src/`, AND NOT THE ROOT, because the root already holds `openRepoShape`,
# the bash launcher: a directory `openreposhape/` beside it is the same name
# on a case-insensitive disk (macOS's and Windows' default), and a clone
# there could not hold both.

[build-system]
# PINNED, as every tool this repository's CI installs is: a build that
# resolves the newest backend on the day it runs is a build whose bytes
# depend on that day. 1.27.0 is the newest hatchling that still runs on
# Python 3.9, the oldest Python this package supports (1.28.0 and later need
# 3.10). Hatchling, because `force-include` below puts `setup-project.py`
# into the package declaratively. Its wheels take their file times from
# `SOURCE_DATE_EPOCH`, and the `package` job builds twice on one runner at
# the commit's time and compares the two files; that is all it shows.
requires = ["hatchling==1.27.0"]
build-backend = "hatchling.build"

[project]
name = "openreposhape"
# THE TAG IS THE ONE SOURCE OF THE VERSION: the release build writes it into
# the stamp, `src/openreposhape/_release.py`, with the tag, the commit and
# the digest, and this reads it from there. The committed stamp says 0.0.0.
dynamic = ["version"]
description = "openRepoShape's commands, run from a clone of the standard at the release's own commit"
requires-python = ">=3.9"
license = "Apache-2.0"
license-files = ["LICENSE"]
authors = [{ name = "opensoft" }]
classifiers = [
"Environment :: Console",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Software Development :: Version Control :: Git",
]
# THE ONE DEPENDENCY, and none of the tools': `adopt-project.py` extracts a
# leg's history with `git filter-repo`. The version CI runs and install.sh
# installs (`.github/workflows/tests.yml`, `install.sh`'s FILTER_REPO_PIN).
# pipx exposes only this package's commands, so each of them puts the
# package environment's scripts directory first on PATH for its children
# whenever a `git-filter-repo` is there (`standard.child_environment`).
dependencies = ["git-filter-repo==2.47.0"]

[project.urls]
Homepage = "https://github.com/opensoft/openRepoShape"
Issues = "https://github.com/opensoft/openRepoShape/issues"

# THE FIVE COMMANDS, each a native `.exe` on Windows. `openRepoShape` is the
# launcher's modes and flags; the other four run that tool from the clone,
# arguments unchanged. The names README, AGENTS.md and `docs/cli.md` already
# use, less `./` and `.py`.
[project.scripts]
openRepoShape = "openreposhape.cli:openreposhape"
scaffold-project = "openreposhape.cli:scaffold_project"
adopt-project = "openreposhape.cli:adopt_project"
update-shape = "openreposhape.cli:update_shape"
shape-doctor = "openreposhape.cli:shape_doctor"

[tool.hatch.version]
path = "src/openreposhape/_release.py"

[tool.hatch.build.targets.wheel]
packages = ["src/openreposhape"]

# The release's own `setup-project.py`, beside the package's modules, which
# is where `standard.preflight_entry` looks for it.
[tool.hatch.build.targets.wheel.force-include]
"setup-project.py" = "openreposhape/setup-project.py"
16 changes: 16 additions & 0 deletions src/openreposhape/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
"""openreposhape: openRepoShape's commands, installed with pipx (#563).

A SECOND WAY IN, NEVER A REQUIREMENT. Cloning this repository and running its
tools with `python3` and `git` keeps working exactly as it does without this
package, and every tool stays standard-library only. The package adds five
commands, `openRepoShape`, `scaffold-project`, `adopt-project`,
`update-shape` and `shape-doctor`, and one dependency, `git-filter-repo`,
which only `adopt-project.py` needs.

IT IS THIN. It carries no tool, template or contract: `cli` runs them from a
full clone of this repository at the commit the package was built from
(`standard`), and `_release` is the stamp that names that commit. The one
file of the standard it does carry is `setup-project.py`, byte for byte, so
that `openRepoShape --preflight` runs on a machine with no git yet.
"""
9 changes: 9 additions & 0 deletions src/openreposhape/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
"""`python -m openreposhape` is `openRepoShape` (#563): the same command,
for an interpreter whose scripts directory is not on PATH."""

import sys

from .cli import openreposhape

sys.exit(openreposhape())
22 changes: 22 additions & 0 deletions src/openreposhape/_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
"""The release this package was built from: its STAMP (#563).

A release build GENERATES this file from the tag it builds, and nothing else
in the wheel differs from the tag's own tree. The four values are the
version (`v1.1.0` gives `1.1.0`), the tag, the tag's 40-hex commit, and the
`sorted-ls-tree-r-v1` digest of that commit's tree, the digest every pin this
standard writes carries (`scripts/repo_shape.py`, `tree_digest`).

THE COMMITTED FILE IS UNSTAMPED: version 0.0.0 and no tag, commit or digest.
That is a build from a checkout rather than a release. It follows main, says
so in `openRepoShape --version`, and the release workflow refuses to publish
it.

`pyproject.toml` reads VERSION from here (`dynamic = ["version"]`), so the
version is written down once.
"""

VERSION = "0.0.0"
TAG = ""
COMMIT = ""
DIGEST = ""
Loading
Loading