Skip to content

Commit 8e7a3ac

Browse files
druvusclaude
andcommitted
fix: include CHANGELOG.md in sdist (MANIFEST.in)
Round 7 packaging audit surfaced that the sdist (the artifact users get from \`pip install\` against a PyPI release or from \`pip install nanorunner.tar.gz\`) shipped without CHANGELOG.md because MANIFEST.in only listed README.md and LICENSE. Wheels included it because pyproject.toml's PEP 517 build picks it up automatically, but the sdist relies on MANIFEST.in. Added it explicitly. Also adds bin/audit_packaging.py with 8 packaging-readiness scenarios: version consistency across pyproject / __init__ / README / CHANGELOG / CLAUDE.md (R); end-to-end \`python -m build\` plus install into a clean venv plus run --version and list-profiles (S); CHANGELOG covers every git tag (T); CI matrix tests the minimum supported Python and pip-installs the package (U). All 8 pass post-fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f9b21ea commit 8e7a3ac

2 files changed

Lines changed: 386 additions & 0 deletions

File tree

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
include README.md
22
include LICENSE
3+
include CHANGELOG.md
34
include requirements.txt
45
recursive-include nanopore_simulator *.py
56
recursive-include bin *

bin/audit_packaging.py

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
#!/usr/bin/env python
2+
"""Round 7: packaging + distribution readiness audit.
3+
4+
Verifies that nanorunner is shippable -- versions match across all
5+
sources of truth, the wheel and sdist build cleanly, the wheel
6+
installs into a fresh virtualenv and the CLI works there, CHANGELOG
7+
covers every release tag, and the CI workflow references the right
8+
matrix.
9+
10+
Usage:
11+
python bin/audit_packaging.py \\
12+
--root /tmp/audit-2026-05-27-round7
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import logging
19+
import re
20+
import shutil
21+
import subprocess
22+
import sys
23+
import time
24+
import traceback
25+
import venv
26+
from dataclasses import dataclass, field
27+
from pathlib import Path
28+
from typing import Dict, List, Optional, Sequence
29+
30+
logger = logging.getLogger("audit-r7")
31+
REPO_ROOT = Path(__file__).resolve().parent.parent
32+
33+
34+
@dataclass
35+
class Finding:
36+
scenario: str
37+
phase: str
38+
passed: bool = True
39+
details: List[str] = field(default_factory=list)
40+
duration_s: float = 0.0
41+
42+
def add(self, m: str) -> None:
43+
self.details.append(m)
44+
45+
def fail(self, m: str) -> None:
46+
self.passed = False
47+
self.details.append(f"FAIL: {m}")
48+
49+
50+
def _reset(p: Path) -> Path:
51+
if p.exists():
52+
shutil.rmtree(p, ignore_errors=True)
53+
p.mkdir(parents=True)
54+
return p
55+
56+
57+
def _run(
58+
args: Sequence[str], cwd: Optional[Path] = None, timeout: int = 300
59+
) -> subprocess.CompletedProcess:
60+
return subprocess.run(
61+
args, capture_output=True, text=True, timeout=timeout, cwd=cwd
62+
)
63+
64+
65+
# ---------------------------------------------------------------------
66+
# Phase R: version + metadata consistency
67+
# ---------------------------------------------------------------------
68+
69+
70+
def phase_r_metadata(root: Path) -> List[Finding]:
71+
findings: List[Finding] = []
72+
73+
f = Finding(scenario="metadata/version_consistency", phase="R")
74+
t0 = time.perf_counter()
75+
try:
76+
# pyproject.toml
77+
py = (REPO_ROOT / "pyproject.toml").read_text()
78+
m = re.search(r'^version\s*=\s*"([^"]+)"', py, re.MULTILINE)
79+
if not m:
80+
f.fail("pyproject.toml: version field not found")
81+
else:
82+
pyproject_v = m.group(1)
83+
f.add(f"pyproject={pyproject_v}")
84+
85+
# __init__.py
86+
init = (REPO_ROOT / "nanopore_simulator" / "__init__.py").read_text()
87+
m = re.search(r'__version__\s*=\s*"([^"]+)"', init)
88+
init_v = m.group(1) if m else None
89+
f.add(f"__init__={init_v}")
90+
if init_v != pyproject_v:
91+
f.fail(f"version mismatch: pyproject={pyproject_v} init={init_v}")
92+
93+
# README badge
94+
readme = (REPO_ROOT / "README.md").read_text()
95+
if pyproject_v not in readme:
96+
f.fail(f"README does not mention version {pyproject_v}")
97+
98+
# CHANGELOG
99+
changelog = (REPO_ROOT / "CHANGELOG.md").read_text()
100+
if f"## [{pyproject_v}]" not in changelog:
101+
f.fail(f"CHANGELOG missing section for {pyproject_v}")
102+
103+
# CLAUDE.md
104+
claude = (REPO_ROOT / "CLAUDE.md").read_text()
105+
if pyproject_v not in claude:
106+
f.fail(f"CLAUDE.md does not mention version {pyproject_v}")
107+
except Exception as exc:
108+
f.fail(f"exception: {exc}\n{traceback.format_exc()}")
109+
f.duration_s = time.perf_counter() - t0
110+
findings.append(f)
111+
112+
# Entry-point declared correctly
113+
f = Finding(scenario="metadata/entry_point_declared", phase="R")
114+
t0 = time.perf_counter()
115+
try:
116+
py = (REPO_ROOT / "pyproject.toml").read_text()
117+
if 'nanorunner = "nanopore_simulator.cli:main"' not in py:
118+
f.fail("entry-point declaration not found in pyproject.toml")
119+
# Verify the target callable actually exists
120+
sys.path.insert(0, str(REPO_ROOT))
121+
try:
122+
from nanopore_simulator import cli as _cli
123+
124+
if not callable(getattr(_cli, "main", None)):
125+
f.fail("nanopore_simulator.cli.main is not callable")
126+
finally:
127+
sys.path.pop(0)
128+
except Exception as exc:
129+
f.fail(f"exception: {exc}")
130+
f.duration_s = time.perf_counter() - t0
131+
findings.append(f)
132+
133+
# MANIFEST.in includes the things the sdist needs
134+
f = Finding(scenario="metadata/manifest_includes_essentials", phase="R")
135+
t0 = time.perf_counter()
136+
try:
137+
manifest = (REPO_ROOT / "MANIFEST.in").read_text()
138+
required = ["README", "LICENSE", "CHANGELOG"]
139+
missing = [r for r in required if r not in manifest]
140+
f.add(f"manifest mentions: {[r for r in required if r in manifest]}")
141+
if missing:
142+
f.fail(f"MANIFEST.in missing: {missing}")
143+
except FileNotFoundError:
144+
f.add("no MANIFEST.in (using pyproject defaults)")
145+
except Exception as exc:
146+
f.fail(f"exception: {exc}")
147+
f.duration_s = time.perf_counter() - t0
148+
findings.append(f)
149+
150+
return findings
151+
152+
153+
# ---------------------------------------------------------------------
154+
# Phase S: build + install in clean venv
155+
# ---------------------------------------------------------------------
156+
157+
158+
def phase_s_build_install(root: Path) -> List[Finding]:
159+
findings: List[Finding] = []
160+
dist_dir = _reset(root / "dist")
161+
venv_dir = _reset(root / "fresh_venv")
162+
163+
# S1: build sdist + wheel
164+
f = Finding(scenario="package/build_sdist_wheel", phase="S")
165+
t0 = time.perf_counter()
166+
try:
167+
proc = _run(
168+
[sys.executable, "-m", "build", "--outdir", str(dist_dir)],
169+
cwd=REPO_ROOT,
170+
timeout=300,
171+
)
172+
f.add(f"exit={proc.returncode}")
173+
if proc.returncode != 0:
174+
f.fail(f"build failed: {proc.stderr[-400:]}")
175+
else:
176+
artifacts = sorted(dist_dir.iterdir())
177+
f.add(f"artifacts={[p.name for p in artifacts]}")
178+
wheels = [p for p in artifacts if p.suffix == ".whl"]
179+
sdists = [p for p in artifacts if p.name.endswith(".tar.gz")]
180+
if not wheels:
181+
f.fail("no wheel produced")
182+
if not sdists:
183+
f.fail("no sdist produced")
184+
except subprocess.TimeoutExpired:
185+
f.fail("build timed out (300s)")
186+
except Exception as exc:
187+
f.fail(f"exception: {exc}\n{traceback.format_exc()}")
188+
f.duration_s = time.perf_counter() - t0
189+
findings.append(f)
190+
191+
if not f.passed:
192+
return findings # No point installing a broken build
193+
194+
# S2: install the wheel into a clean venv and run sanity commands
195+
wheel = next((p for p in dist_dir.glob("*.whl")), None)
196+
if wheel is None:
197+
return findings
198+
199+
f = Finding(scenario="package/install_into_clean_venv", phase="S")
200+
t0 = time.perf_counter()
201+
try:
202+
venv.create(venv_dir, with_pip=True, clear=True)
203+
py = venv_dir / "bin" / "python"
204+
pip_install = _run(
205+
[str(py), "-m", "pip", "install", str(wheel)],
206+
timeout=180,
207+
)
208+
f.add(f"pip_install_exit={pip_install.returncode}")
209+
if pip_install.returncode != 0:
210+
f.fail(f"pip install failed: {pip_install.stderr[-400:]}")
211+
else:
212+
# Now run the CLI from the venv
213+
cli = venv_dir / "bin" / "nanorunner"
214+
if not cli.exists():
215+
f.fail(f"console_script not installed at {cli}")
216+
else:
217+
ver = _run([str(cli), "--version"], timeout=30)
218+
f.add(f"version_exit={ver.returncode} stdout={ver.stdout.strip()!r}")
219+
if ver.returncode != 0:
220+
f.fail(f"--version failed: {ver.stderr[-300:]}")
221+
if "3.1.0" not in ver.stdout:
222+
f.fail("--version did not print 3.1.0")
223+
# And a subcommand
224+
lp = _run([str(cli), "list-profiles"], timeout=30)
225+
if lp.returncode != 0:
226+
f.fail(f"list-profiles failed: {lp.stderr[-300:]}")
227+
if "generate_test" not in lp.stdout:
228+
f.fail("list-profiles output missing known content")
229+
except Exception as exc:
230+
f.fail(f"exception: {exc}\n{traceback.format_exc()}")
231+
f.duration_s = time.perf_counter() - t0
232+
findings.append(f)
233+
234+
# S3: sdist contents include README + LICENSE + CHANGELOG
235+
sdist = next((p for p in dist_dir.glob("*.tar.gz")), None)
236+
if sdist is not None:
237+
f = Finding(scenario="package/sdist_contains_docs", phase="S")
238+
t0 = time.perf_counter()
239+
try:
240+
proc = _run(["tar", "-tzf", str(sdist)], timeout=30)
241+
names = proc.stdout.splitlines()
242+
f.add(f"entries={len(names)}")
243+
for required in ("README.md", "LICENSE", "CHANGELOG.md"):
244+
if not any(required in n for n in names):
245+
f.fail(f"sdist missing {required}")
246+
except Exception as exc:
247+
f.fail(f"exception: {exc}")
248+
f.duration_s = time.perf_counter() - t0
249+
findings.append(f)
250+
251+
return findings
252+
253+
254+
# ---------------------------------------------------------------------
255+
# Phase T: CHANGELOG vs git tags
256+
# ---------------------------------------------------------------------
257+
258+
259+
def phase_t_changelog(root: Path) -> List[Finding]:
260+
findings: List[Finding] = []
261+
f = Finding(scenario="docs/changelog_covers_tags", phase="T")
262+
t0 = time.perf_counter()
263+
try:
264+
changelog = (REPO_ROOT / "CHANGELOG.md").read_text()
265+
sections = set(re.findall(r"^## \[([0-9.]+)\]", changelog, re.MULTILINE))
266+
f.add(f"changelog_versions={sorted(sections)}")
267+
268+
tags = _run(["git", "tag", "--list", "v*"], cwd=REPO_ROOT, timeout=10)
269+
tag_versions = {
270+
t.lstrip("v") for t in tags.stdout.split() if re.match(r"v\d", t)
271+
}
272+
f.add(f"git_tags={sorted(tag_versions)}")
273+
274+
missing = tag_versions - sections
275+
if missing:
276+
f.fail(f"CHANGELOG missing entries for tags: {sorted(missing)}")
277+
# Tagless versions in CHANGELOG are fine (current dev), so we
278+
# don't fail on sections - tags.
279+
except Exception as exc:
280+
f.fail(f"exception: {exc}\n{traceback.format_exc()}")
281+
f.duration_s = time.perf_counter() - t0
282+
findings.append(f)
283+
return findings
284+
285+
286+
# ---------------------------------------------------------------------
287+
# Phase U: CI workflow sanity
288+
# ---------------------------------------------------------------------
289+
290+
291+
def phase_u_ci(root: Path) -> List[Finding]:
292+
findings: List[Finding] = []
293+
f = Finding(scenario="ci/workflow_matrix_matches_pyproject", phase="U")
294+
t0 = time.perf_counter()
295+
try:
296+
py = (REPO_ROOT / "pyproject.toml").read_text()
297+
m = re.search(r'requires-python\s*=\s*"([^"]+)"', py)
298+
required = m.group(1) if m else None
299+
f.add(f"requires-python={required}")
300+
301+
ci_path = REPO_ROOT / ".github" / "workflows" / "ci.yml"
302+
if not ci_path.exists():
303+
f.fail("no CI workflow found")
304+
else:
305+
ci = ci_path.read_text()
306+
# Spot-check: requires-python ">=3.9" should mean CI tests
307+
# at least the 3.9 floor.
308+
if required and ">=3.9" in required and '"3.9"' not in ci:
309+
f.fail("CI does not test the minimum supported Python 3.9")
310+
# Spot-check: CI installs the package (no missing step)
311+
if "pip install -e ." not in ci:
312+
f.fail("CI does not pip-install the package before testing")
313+
except Exception as exc:
314+
f.fail(f"exception: {exc}")
315+
f.duration_s = time.perf_counter() - t0
316+
findings.append(f)
317+
318+
return findings
319+
320+
321+
# ---------------------------------------------------------------------
322+
# Report
323+
# ---------------------------------------------------------------------
324+
325+
326+
def write_report(root: Path, findings: List[Finding]) -> Path:
327+
path = root / "reports" / "packaging-report.md"
328+
path.parent.mkdir(parents=True, exist_ok=True)
329+
failed = [f for f in findings if not f.passed]
330+
by_phase: Dict[str, List[Finding]] = {}
331+
for f in findings:
332+
by_phase.setdefault(f.phase, []).append(f)
333+
lines = [
334+
"# nanorunner packaging audit (round 7)",
335+
"",
336+
f"- total scenarios: {len(findings)}",
337+
f"- failed: {len(failed)}",
338+
f"- passed: {len(findings) - len(failed)}",
339+
"",
340+
]
341+
for phase in sorted(by_phase):
342+
lines.append(f"## Phase {phase}")
343+
lines.append("")
344+
lines.append("| scenario | result | seconds | details |")
345+
lines.append("|---|---|---|---|")
346+
for f in by_phase[phase]:
347+
status = "PASS" if f.passed else "FAIL"
348+
joined = "<br>".join(d.replace("|", "\\|") for d in f.details)
349+
lines.append(f"| {f.scenario} | {status} | {f.duration_s:.2f} | {joined} |")
350+
lines.append("")
351+
if failed:
352+
lines.append("## Failure detail")
353+
for f in failed:
354+
lines.append(f"### {f.scenario}")
355+
for d in f.details:
356+
lines.append(f"- {d}")
357+
lines.append("")
358+
path.write_text("\n".join(lines))
359+
return path
360+
361+
362+
def main(argv: Optional[List[str]] = None) -> int:
363+
p = argparse.ArgumentParser()
364+
p.add_argument("--root", required=True, type=Path)
365+
args = p.parse_args(argv)
366+
logging.basicConfig(
367+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
368+
)
369+
root: Path = args.root
370+
root.mkdir(parents=True, exist_ok=True)
371+
(root / "reports").mkdir(exist_ok=True)
372+
373+
findings: List[Finding] = []
374+
findings.extend(phase_r_metadata(root))
375+
findings.extend(phase_s_build_install(root))
376+
findings.extend(phase_t_changelog(root))
377+
findings.extend(phase_u_ci(root))
378+
379+
report = write_report(root, findings)
380+
logger.info("wrote %s", report)
381+
return 1 if any(not f.passed for f in findings) else 0
382+
383+
384+
if __name__ == "__main__":
385+
sys.exit(main())

0 commit comments

Comments
 (0)