Skip to content

Commit 44b5760

Browse files
committed
test(downstream): run released Bandit baseline tests against this checkout
Exercise a current high-download GitPython consumer with its unchanged upstream tests. Add a shared `uv` runner that resolves the latest PyPI release, retrieves verified source, installs a private environment, and replaces the released GitPython dependency with this editable checkout. Verify the imported `git` module before testing and retain source provenance, frozen requirements, and JUnit results for diagnosis and reproduction. Clear inherited Git repository/configuration settings and Python import paths so upstream commits and resets use their own fixtures. Use pytest's long `--override-ini` spelling because Bandit's CLI tests interpret `-o` as a forbidden Bandit output option. Reject successful runs with no passing tests, including entirely skipped suites. Add a CI job for the latest Bandit release, with Git 2.52 or newer, and local usage and download-ranking documentation. Bandit 1.9.4 passes all 12 selected tests against this checkout on Python 3.12 and Git 2.54, even with deliberately invalid inherited Git directory, index, and config settings. Ruff, workflow YAML parsing, and whitespace checks also pass. No GitPython compatibility changes were needed.
1 parent fcdda29 commit 44b5760

4 files changed

Lines changed: 269 additions & 0 deletions

File tree

‎.github/workflows/downstream.yml‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Downstream compatibility
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 30
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
project: [bandit]
20+
steps:
21+
- uses: actions/checkout@v7
22+
with:
23+
persist-credentials: false
24+
- name: Ensure Git 2.52 or newer
25+
run: |
26+
if ! dpkg --compare-versions "$(git version | awk '{print $3}')" ge 2.52; then
27+
sudo add-apt-repository --yes ppa:git-core/ppa
28+
sudo apt-get update
29+
sudo apt-get install --yes git
30+
fi
31+
- name: Install uv
32+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
33+
with:
34+
version: '0.12.13'
35+
enable-cache: false
36+
- name: Test latest release against this checkout
37+
run: uv run test/downstream/run.py "${{ matrix.project }}"

‎test/downstream/README.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Downstream compatibility
2+
3+
Run released projects' GitPython-related tests against this checkout with
4+
[uv](https://docs.astral.sh/uv/) and Git 2.52 or newer:
5+
6+
```sh
7+
uv run test/downstream/run.py bandit
8+
uv run test/downstream/run.py bandit --version 1.9.4
9+
```
10+
11+
The default resolves the latest release from PyPI each time. `--version` reproduces
12+
a release; `--python` selects the test interpreter (default: 3.12). The runner
13+
creates a fresh environment, replaces GitPython with this editable checkout, and checks
14+
the imported module before testing. Upstream test files are not modified.
15+
16+
Source, the environment, frozen requirements, JUnit results, and release provenance
17+
are retained under `.cache/downstream/`. `--work-dir PATH` uses a new directory
18+
elsewhere. Delete retained directories when no longer needed. PyPI source archives
19+
are verified against their published SHA-256 digest and extracted with Python's
20+
safe data filter. Each run uses a private Git configuration, including an identity
21+
and the `master` initial branch expected by upstream fixtures. Inherited Git
22+
settings and Python import paths are cleared before setup and testing. Select a
23+
different Git executable by putting its directory first on `PATH`.
24+
25+
## Selection and coverage
26+
27+
The selection uses current runtime users, including optional end-user features,
28+
ranked by September 2026 PyPI distribution downloads. Development, documentation,
29+
and test-only dependencies are excluded. Downloads are not unique installations.
30+
31+
| Project | Distribution downloads | Last tested release | Selected coverage |
32+
| --- | ---: | --- | --- |
33+
| Bandit | 24,935,372 | 1.9.4 | 12 upstream baseline CLI tests: real repository creation, commits, branches, resets, discovery, and dirty state |
34+
35+
Source: [top-pypi-packages](https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json),
36+
snapshot updated **2026-10-01 12:40:51 UTC**. Its
37+
[ClickHouse query](https://github.com/hugovk/top-pypi-packages/blob/main/top-pypi-clickhouse.py)
38+
covers the previous calendar month. Current metadata for the top 5,000
39+
distributions was checked, together with known runtime integrations.
40+
41+
Bandit's GitPython dependency belongs to its user-facing `baseline` extra.
42+
Two selected tests mock error paths; the others use real repositories.
43+
CI runs the same command against the latest release and fails when no test passes,
44+
including when all selected tests are skipped. Test dependency ranges only supply
45+
the upstream test harness; they do not pin the dependent's release.

‎test/downstream/projects.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"bandit": {
3+
"distribution": "bandit",
4+
"source": "sdist",
5+
"install": ["{source}[baseline]", "pytest", "fixtures", "testtools"],
6+
"tests": ["tests/unit/cli/test_baseline.py"]
7+
}
8+
}

‎test/downstream/run.py‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# /// script
2+
# requires-python = ">=3.12"
3+
# ///
4+
"""Run one released downstream project's GitPython tests in an isolated uv environment."""
5+
6+
import argparse
7+
import hashlib
8+
import json
9+
import os
10+
from pathlib import Path
11+
import re
12+
import shlex
13+
import subprocess
14+
import sys
15+
import tarfile
16+
import tempfile
17+
from urllib.parse import quote, urlparse
18+
from urllib.request import urlopen
19+
import xml.etree.ElementTree as ET
20+
21+
22+
HERE = Path(__file__).resolve().parent
23+
CHECKOUT = HERE.parent.parent
24+
25+
26+
def run(command, *, cwd=None, env=None, check=True, **kwargs):
27+
print("+", shlex.join(map(str, command)), flush=True)
28+
return subprocess.run(command, cwd=cwd, env=env, check=check, **kwargs)
29+
30+
31+
def release_metadata(distribution, version):
32+
suffix = "" if version is None else "/" + quote(version, safe="")
33+
url = f"https://pypi.org/pypi/{distribution}{suffix}/json"
34+
with urlopen(url, timeout=60) as response:
35+
metadata = json.load(response)
36+
version = metadata["info"]["version"]
37+
if not re.fullmatch(r"[0-9][A-Za-z0-9.!+_-]*", version):
38+
raise ValueError(f"Unexpected release version: {version!r}")
39+
return metadata, version
40+
41+
42+
def source_tree(profile, metadata, version, work, env):
43+
source = work / "source"
44+
if profile["source"] == "git":
45+
tag = profile["tag"].format(version=version)
46+
run(
47+
[
48+
"git",
49+
"-c",
50+
f"core.hooksPath={os.devnull}",
51+
"clone",
52+
"--depth=1",
53+
"--branch",
54+
tag,
55+
"--",
56+
profile["repository"],
57+
str(source),
58+
],
59+
env=env,
60+
)
61+
commit = run(
62+
["git", "-C", str(source), "rev-parse", "HEAD"], env=env, capture_output=True, text=True
63+
).stdout.strip()
64+
return source, {"repository": profile["repository"], "tag": tag, "commit": commit}
65+
66+
sdist = next(item for item in metadata["urls"] if item["packagetype"] == "sdist" and not item["yanked"])
67+
url = urlparse(sdist["url"])
68+
if url.scheme != "https" or url.hostname != "files.pythonhosted.org":
69+
raise ValueError("Expected an HTTPS source archive hosted by PyPI")
70+
archive = work / "source.tar.gz"
71+
digest = hashlib.sha256()
72+
with urlopen(sdist["url"], timeout=60) as response, archive.open("wb") as output:
73+
while block := response.read(1024 * 1024):
74+
digest.update(block)
75+
output.write(block)
76+
if digest.hexdigest() != sdist["digests"]["sha256"]:
77+
raise ValueError("Source archive does not match PyPI's SHA-256 digest")
78+
source.mkdir()
79+
with tarfile.open(archive) as contents:
80+
contents.extractall(source, filter="data")
81+
roots = list(source.iterdir())
82+
if len(roots) != 1 or not roots[0].is_dir():
83+
raise ValueError("Expected one source directory in the release archive")
84+
return roots[0], {"url": sdist["url"], "sha256": digest.hexdigest()}
85+
86+
87+
def main():
88+
profiles = json.loads((HERE / "projects.json").read_text())
89+
parser = argparse.ArgumentParser(description=__doc__)
90+
parser.add_argument("project", choices=profiles)
91+
parser.add_argument("--version", help="Release to reproduce; defaults to PyPI's latest release")
92+
parser.add_argument("--python", default="3.12", help="Python used by the isolated test environment")
93+
parser.add_argument("--work-dir", type=Path, help="New directory for retained source, environment, and results")
94+
args = parser.parse_args()
95+
profile = profiles[args.project]
96+
metadata, version = release_metadata(profile["distribution"], args.version)
97+
if args.work_dir:
98+
work = args.work_dir.resolve()
99+
work.mkdir(parents=True)
100+
else:
101+
cache = CHECKOUT / ".cache" / "downstream"
102+
cache.mkdir(parents=True, exist_ok=True)
103+
work = Path(tempfile.mkdtemp(prefix=f"{args.project}-{version}-", dir=cache))
104+
print(f"Testing {args.project} {version}; retained work directory: {work}", flush=True)
105+
106+
config = work / "gitconfig"
107+
config.write_text(
108+
"[user]\n\tname = GitPython downstream tests\n\temail = tests@example.invalid\n"
109+
"[init]\n\tdefaultBranch = master\n[commit]\n\tgpgSign = false\n"
110+
)
111+
# Do not let inherited Git settings redirect upstream resets or commits into
112+
# the caller's repository, index, object database, configuration, or hooks.
113+
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
114+
env.pop("PYTHONPATH", None)
115+
env.pop("PYTHONHOME", None)
116+
env.update(GIT_CONFIG_GLOBAL=str(config), GIT_CONFIG_NOSYSTEM="1", GIT_TERMINAL_PROMPT="0")
117+
source, provenance = source_tree(profile, metadata, version, work, env)
118+
venv = work / "venv"
119+
run(["uv", "venv", "--python", args.python, str(venv)], env=env)
120+
bin_dir = venv / ("Scripts" if os.name == "nt" else "bin")
121+
python = bin_dir / ("python.exe" if os.name == "nt" else "python")
122+
env["PATH"] = str(bin_dir) + os.pathsep + env["PATH"]
123+
env["GITPYTHON_CHECKOUT"] = str(CHECKOUT)
124+
for key in profile.get("unset_env", []):
125+
env.pop(key, None)
126+
env.update(profile.get("env", {}))
127+
128+
def expand(value):
129+
return value.format(version=version, source=source, checks=HERE / "checks")
130+
131+
install = ["uv", "pip", "install", "--python", str(python)]
132+
run(install + list(map(expand, profile["install"])), env=env)
133+
if profile.get("no_deps"):
134+
run(install + ["--no-deps"] + list(map(expand, profile["no_deps"])), env=env)
135+
# Replace the released dependency even if the downstream pins another version.
136+
run(install + ["--reinstall-package", "gitpython", "-e", str(CHECKOUT)], env=env)
137+
138+
# Check the import used by tests and by downstream Python subprocesses.
139+
run(
140+
[
141+
str(python),
142+
"-c",
143+
"import os, pathlib, git; "
144+
"expected = pathlib.Path(os.environ['GITPYTHON_CHECKOUT']) / 'git' / '__init__.py'; "
145+
"assert pathlib.Path(git.__file__).resolve() == expected.resolve(), git.__file__; "
146+
"print('GitPython:', git.__file__); print(git.Git().version()); "
147+
"assert git.Git().version_info >= (2, 52), 'Git 2.52 or newer is required'",
148+
],
149+
cwd=source,
150+
env=env,
151+
)
152+
with (work / "requirements-frozen.txt").open("w") as output:
153+
run(["uv", "pip", "freeze", "--python", str(python)], env=env, stdout=output)
154+
result = {"project": args.project, "version": version, "source": provenance, "gitpython": str(CHECKOUT)}
155+
result_path = work / "result.json"
156+
result_path.write_text(json.dumps(result, indent=2) + "\n")
157+
report = work / "junit.xml"
158+
# Bandit's CLI tests inspect sys.argv and reserve the short spelling `-o`.
159+
command = [str(python), "-m", "pytest", "-q", "--override-ini=addopts=", "--tb=short", f"--junitxml={report}"]
160+
command += list(map(expand, profile["tests"])) + profile.get("pytest_args", [])
161+
completed = run(command, cwd=source / profile.get("cwd", ""), env=env, check=False)
162+
result["exit_code"] = completed.returncode
163+
if report.exists():
164+
cases = list(ET.parse(report).iter("testcase"))
165+
result["passed"] = sum(
166+
not any(case.find(tag) is not None for tag in ("failure", "error", "skipped")) for case in cases
167+
)
168+
result["skipped"] = sum(case.find("skipped") is not None for case in cases)
169+
result_path.write_text(json.dumps(result, indent=2) + "\n")
170+
print(json.dumps(result, indent=2), flush=True)
171+
if completed.returncode:
172+
return completed.returncode
173+
if not result.get("passed"):
174+
raise RuntimeError("No downstream tests passed; an empty or entirely skipped suite is not validation")
175+
return 0
176+
177+
178+
if __name__ == "__main__":
179+
sys.exit(main())

0 commit comments

Comments
 (0)