From e29035f768297a634c7eafe3923a158f632bf294 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 4 Oct 2026 18:26:49 +0000 Subject: [PATCH] Release builds must not write tracked files; validate instead release.py previously ran the code generator, 'ruff check --fix', 'ruff format', and line-ending normalization before building. Any drift rewrote tracked files, dirtying the checkout and turning the release into a dev version (the v0.10.6 PyPI 400). Generation and linting are now validation-only: - tools/generate.py gains a --check mode that reports stale generated files without writing (comparing Python content in ruff-formatted form, matching what write + format would produce) - release.py runs generate --check, ruff check (no --fix), and ruff format --check, failing with remediation hints - normalize_line_endings() is removed; .gitattributes keeps checkouts LF --- tools/generate.py | 95 ++++++++++++++++++++++++++++++++++------------- tools/release.py | 54 ++++++++------------------- 2 files changed, 84 insertions(+), 65 deletions(-) diff --git a/tools/generate.py b/tools/generate.py index 3b27bc7..1e0b3ab 100755 --- a/tools/generate.py +++ b/tools/generate.py @@ -73,6 +73,42 @@ def format_declaration(decl: str, max_width: int = 100) -> str: return decl +def _ruff_format_source(content: str, filename: str) -> str: + """Format Python source in memory (ruff format via stdin, no file writes).""" + result = subprocess.run( + ["uv", "run", "ruff", "format", "--stdin-filename", filename, "-"], + input=content, + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def _is_up_to_date(path: pathlib.Path, content: str, check: bool) -> bool: + """True if writing content to path would leave it unchanged. In check mode, + Python content is compared in its ruff-formatted form, matching what a + write + format pass would produce.""" + if not path.exists(): + return False + if check and path.suffix == ".py": + content = _ruff_format_source(content, path.name) + return path.read_text(encoding="utf-8") == content + + +def _sync_file( + path: pathlib.Path, content: str, check: bool, stale: list[pathlib.Path] +) -> None: + """Write content to path, or in check mode record it as stale instead.""" + if _is_up_to_date(path, content, check): + print(f" - No changes to {path.name}", file=sys.stderr) + elif check: + stale.append(path) + else: + path.write_bytes(content.encode()) + print(f" - Updated {path}", file=sys.stderr) + + def generate_cdef(include_dir: pathlib.Path) -> str: # cffi pre-defines uint8_t, size_t, etc. with the correct platform sizes. # Do not typedef them here: "unsigned long" matches size_t on LP64 but is @@ -240,6 +276,7 @@ def generate_python_modules( template_path: pathlib.Path, output_dir: pathlib.Path, constants: dict[str, dict[str, int]], + check: bool = False, ) -> tuple[list[pathlib.Path], list[pathlib.Path]]: if not template_path.exists(): raise FileNotFoundError(f"Template not found: {template_path}") @@ -287,10 +324,11 @@ def generate_python_modules( else: new_content = generate_variant(template_src, variant, const_dict) - if dst.exists() and dst.read_text(encoding="utf-8") == new_content: + if _is_up_to_date(dst, new_content, check): unchanged.append(dst) else: - dst.write_bytes(new_content.encode()) + if not check: + dst.write_bytes(new_content.encode()) updated.append(dst) return updated, unchanged @@ -313,7 +351,9 @@ def generate_ciphers_module(constants: dict[str, dict[str, int]]) -> str: return "\n".join(lines) + "\n" -def main() -> int: +def main(check: bool = False) -> int: + """Regenerate files from the C sources. With check=True, only verify the + committed files are up to date; nothing is written.""" root = pathlib.Path(__file__).parent.parent libaegis_src_dir = root / "libaegis" / "src" include_dir = libaegis_src_dir / "include" @@ -333,36 +373,26 @@ def main() -> int: print("Error: No constants extracted", file=sys.stderr) return 1 - print("Step 2: Generating CFFI cdef header...", file=sys.stderr) - pyaegis_dir.mkdir(exist_ok=True) - cdef_path = pyaegis_dir / "aegis_cdef.h" - cdef_content = generate_cdef(include_dir) + stale: list[pathlib.Path] = [] - if cdef_path.exists() and cdef_path.read_text(encoding="utf-8") == cdef_content: - print(f" - No changes to {cdef_path}", file=sys.stderr) - else: - cdef_path.write_bytes(cdef_content.encode()) - print(f" - Updated {cdef_path}", file=sys.stderr) + print("Step 2: Generating CFFI cdef header...", file=sys.stderr) + if not check: + pyaegis_dir.mkdir(exist_ok=True) + _sync_file(pyaegis_dir / "aegis_cdef.h", generate_cdef(include_dir), check, stale) print("Step 3: Generating _ciphers.py...", file=sys.stderr) - ciphers_path = pyaegis_dir / "_ciphers.py" - ciphers_content = generate_ciphers_module(constants) - - if ( - ciphers_path.exists() - and ciphers_path.read_text(encoding="utf-8") == ciphers_content - ): - print(f" - No changes to {ciphers_path.name}", file=sys.stderr) - else: - ciphers_path.write_bytes(ciphers_content.encode()) - print(f" - Updated {ciphers_path.name}", file=sys.stderr) + _sync_file( + pyaegis_dir / "_ciphers.py", generate_ciphers_module(constants), check, stale + ) print("Step 4: Generating Python modules...", file=sys.stderr) try: updated, unchanged = generate_python_modules( - pyaegis_dir / "aegis256x4.py", pyaegis_dir, constants + pyaegis_dir / "aegis256x4.py", pyaegis_dir, constants, check=check ) - if updated: + if check: + stale.extend(updated) + else: for p in updated: print(f" - {p.relative_to(root)}", file=sys.stderr) if unchanged: @@ -377,6 +407,19 @@ def main() -> int: print(f"Error generating Python modules: {e}", file=sys.stderr) return 1 + if check: + for p in stale: + print(f" - Out of date: {p.relative_to(root)}", file=sys.stderr) + if stale: + print( + "✗ Generated files are out of date; " + "run tools/generate.py and commit the result", + file=sys.stderr, + ) + return 1 + print("All generated files are up to date.", file=sys.stderr) + return 0 + print("Step 5: Formatting generated files with ruff...", file=sys.stderr) result = subprocess.run( ["uv", "run", "ruff", "format", str(pyaegis_dir)], @@ -392,4 +435,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main(check="--check" in sys.argv)) diff --git a/tools/release.py b/tools/release.py index 217266c..f1b9835 100755 --- a/tools/release.py +++ b/tools/release.py @@ -129,29 +129,6 @@ def get_build_env(): return env -def normalize_line_endings(repo_root: Path): - """Normalize all text files to LF line endings.""" - # Patterns for files to normalize - patterns = [ - "src/aeg/**/*.py", - "src/aeg/**/*.h", - "tests/**/*.py", - "tools/**/*.py", - "*.py", - "*.md", - "*.txt", - "*.toml", - "*.in", - ] - for pattern in patterns: - for file_path in repo_root.glob(pattern): - if file_path.is_file(): - content = file_path.read_bytes() - if b"\r\n" in content: - content = content.replace(b"\r\n", b"\n") - file_path.write_bytes(content) - - def get_wheel_pattern(py_version: str, abi3: bool = False) -> str: """Get the glob pattern for finding a wheel file.""" if abi3: @@ -317,30 +294,29 @@ def main(): repo_root = Path(__file__).parent.parent dist_dir = repo_root / "dist" - # Generate CFFI definitions and Python modules + # The repository must already contain up-to-date generated code and + # lint-clean sources: a release build never writes to tracked files. + # Line endings are normalized to LF on checkout via .gitattributes. print(f"\n{'=' * 70}") - print("Code generation from C headers (tools/generate.py)") + print("Validating generated files (tools/generate.py --check)") print(f"{'=' * 70}") - if generate.main() != 0: - print("✗ Code generation failed", file=sys.stderr) + if generate.main(check=True) != 0: return 1 - # Run ruff to check and fix any issues print(f"\n{'=' * 70}") - print("Linting and formatting") + print("Lint and format validation") print(f"{'=' * 70}") - if not run_command(["uv", "run", "ruff", "check", "--fix", "."]): - print("✗ Ruff check failed", file=sys.stderr) + if not run_command(["uv", "run", "ruff", "check", "."]): + print("✗ Ruff check failed; fix and commit before releasing", file=sys.stderr) return 1 - # Run ruff format - if not run_command(["uv", "run", "ruff", "format", "."]): - print("✗ Ruff format failed", file=sys.stderr) + if not run_command(["uv", "run", "ruff", "format", "--check", "."]): + print( + "✗ Ruff format check failed; run 'uv run ruff format .' and commit", + file=sys.stderr, + ) return 1 - # Normalize all line endings to LF (important for consistent builds) - normalize_line_endings(repo_root) - # Get version from git repo version = get_version_from_scm() if not version: @@ -348,8 +324,8 @@ def main(): is_release = is_release_version(version) # On a tagged commit the version must be a clean release (e.g. 0.10.6). - # A dev/dirty version means the steps above rewrote a tracked file (or the - # checkout was dirty); PyPI rejects such versions, so fail before building. + # A dev/dirty version means the checkout itself is dirty (PyPI rejects + # such versions), so fail before building. head_tags = subprocess.run( ["git", "tag", "--points-at", "HEAD", "--list", "v*"], capture_output=True,