3 Commits
Author SHA1 Message Date
LeoVasanko c9ae37a03d Validate LF line endings in release builds
release / build-wheels (linux, bash) (push) Successful in 1m8s
release / build-wheels (windows, cmd) (push) Successful in 1m19s
release / build-wheels (macos, bash) (push) Successful in 1m20s
release / publish-pypi (push) Successful in 15s
Replaces the removed normalize_line_endings rewriter: release.py now
fails listing any text file containing CRLF instead of silently
rewriting it. .gitattributes (eol=lf) remains the preventive half.
2026-10-04 18:30:28 +00:00
LeoVasanko e29035f768 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
2026-10-04 18:26:49 +00:00
LeoVasanko 3ff145a0f0 Fix release versioning: keep tree ruff-clean, fail fast on dirty tag builds
The tagged v0.10.6 CI build produced wheels versioned
0.10.7.dev0+g503ce8e49.d20261004: setup.py was not ruff-format-clean, so
release.py's own 'ruff format .' step rewrote it, dirtying the checkout
before setuptools_scm ran. PyPI then rejected the local version segment
with HTTP 400.

- ruff-format setup.py
- release.py: on a v*-tagged HEAD, fail before building if the version
  is not a clean release, printing the dirty files
- add .gitattributes (eol=lf) so Windows checkouts can't dirty the tree
  via normalize_line_endings either
2026-10-04 18:15:07 +00:00
5 changed files with 146 additions and 61 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+1
View File
@@ -8,5 +8,6 @@
/src/aeg/_aegis*.pyd /src/aeg/_aegis*.pyd
__pycache__ __pycache__
!.gitignore !.gitignore
!.gitattributes
!.gitea/ !.gitea/
!.gitmodules !.gitmodules
+4 -2
View File
@@ -14,8 +14,10 @@ if sys.maxsize <= 2**32:
"aeg does not support 32-bit platforms; build with a 64-bit Python" "aeg does not support 32-bit platforms; build with a 64-bit Python"
) )
libaegis_static = ROOT / "libaegis/zig-out/lib" / ( libaegis_static = (
"aegis.lib" if sys.platform == "win32" else "libaegis.a" ROOT
/ "libaegis/zig-out/lib"
/ ("aegis.lib" if sys.platform == "win32" else "libaegis.a")
) )
ffibuilder = FFI() ffibuilder = FFI()
+69 -26
View File
@@ -73,6 +73,42 @@ def format_declaration(decl: str, max_width: int = 100) -> str:
return decl 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: def generate_cdef(include_dir: pathlib.Path) -> str:
# cffi pre-defines uint8_t, size_t, etc. with the correct platform sizes. # 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 # 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, template_path: pathlib.Path,
output_dir: pathlib.Path, output_dir: pathlib.Path,
constants: dict[str, dict[str, int]], constants: dict[str, dict[str, int]],
check: bool = False,
) -> tuple[list[pathlib.Path], list[pathlib.Path]]: ) -> tuple[list[pathlib.Path], list[pathlib.Path]]:
if not template_path.exists(): if not template_path.exists():
raise FileNotFoundError(f"Template not found: {template_path}") raise FileNotFoundError(f"Template not found: {template_path}")
@@ -287,10 +324,11 @@ def generate_python_modules(
else: else:
new_content = generate_variant(template_src, variant, const_dict) 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) unchanged.append(dst)
else: else:
dst.write_bytes(new_content.encode()) if not check:
dst.write_bytes(new_content.encode())
updated.append(dst) updated.append(dst)
return updated, unchanged return updated, unchanged
@@ -313,7 +351,9 @@ def generate_ciphers_module(constants: dict[str, dict[str, int]]) -> str:
return "\n".join(lines) + "\n" 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 root = pathlib.Path(__file__).parent.parent
libaegis_src_dir = root / "libaegis" / "src" libaegis_src_dir = root / "libaegis" / "src"
include_dir = libaegis_src_dir / "include" include_dir = libaegis_src_dir / "include"
@@ -333,36 +373,26 @@ def main() -> int:
print("Error: No constants extracted", file=sys.stderr) print("Error: No constants extracted", file=sys.stderr)
return 1 return 1
print("Step 2: Generating CFFI cdef header...", file=sys.stderr) stale: list[pathlib.Path] = []
pyaegis_dir.mkdir(exist_ok=True)
cdef_path = pyaegis_dir / "aegis_cdef.h"
cdef_content = generate_cdef(include_dir)
if cdef_path.exists() and cdef_path.read_text(encoding="utf-8") == cdef_content: print("Step 2: Generating CFFI cdef header...", file=sys.stderr)
print(f" - No changes to {cdef_path}", file=sys.stderr) if not check:
else: pyaegis_dir.mkdir(exist_ok=True)
cdef_path.write_bytes(cdef_content.encode()) _sync_file(pyaegis_dir / "aegis_cdef.h", generate_cdef(include_dir), check, stale)
print(f" - Updated {cdef_path}", file=sys.stderr)
print("Step 3: Generating _ciphers.py...", file=sys.stderr) print("Step 3: Generating _ciphers.py...", file=sys.stderr)
ciphers_path = pyaegis_dir / "_ciphers.py" _sync_file(
ciphers_content = generate_ciphers_module(constants) pyaegis_dir / "_ciphers.py", generate_ciphers_module(constants), check, stale
)
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)
print("Step 4: Generating Python modules...", file=sys.stderr) print("Step 4: Generating Python modules...", file=sys.stderr)
try: try:
updated, unchanged = generate_python_modules( 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: for p in updated:
print(f" - {p.relative_to(root)}", file=sys.stderr) print(f" - {p.relative_to(root)}", file=sys.stderr)
if unchanged: if unchanged:
@@ -377,6 +407,19 @@ def main() -> int:
print(f"Error generating Python modules: {e}", file=sys.stderr) print(f"Error generating Python modules: {e}", file=sys.stderr)
return 1 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) print("Step 5: Formatting generated files with ruff...", file=sys.stderr)
result = subprocess.run( result = subprocess.run(
["uv", "run", "ruff", "format", str(pyaegis_dir)], ["uv", "run", "ruff", "format", str(pyaegis_dir)],
@@ -392,4 +435,4 @@ def main() -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main(check="--check" in sys.argv))
+71 -33
View File
@@ -129,27 +129,29 @@ def get_build_env():
return env return env
def normalize_line_endings(repo_root: Path): # Text files that must have LF line endings (.gitattributes enforces this on
"""Normalize all text files to LF line endings.""" # checkout; release validation fails instead of rewriting)
# Patterns for files to normalize LINE_ENDING_PATTERNS = [
patterns = [ "src/aeg/**/*.py",
"src/aeg/**/*.py", "src/aeg/**/*.h",
"src/aeg/**/*.h", "tests/**/*.py",
"tests/**/*.py", "tools/**/*.py",
"tools/**/*.py", "*.py",
"*.py", "*.md",
"*.md", "*.txt",
"*.txt", "*.toml",
"*.toml", "*.in",
"*.in", ]
]
for pattern in patterns:
def find_crlf_files(repo_root: Path) -> list[Path]:
"""Return text files containing CRLF line endings."""
bad = []
for pattern in LINE_ENDING_PATTERNS:
for file_path in repo_root.glob(pattern): for file_path in repo_root.glob(pattern):
if file_path.is_file(): if file_path.is_file() and b"\r\n" in file_path.read_bytes():
content = file_path.read_bytes() bad.append(file_path)
if b"\r\n" in content: return bad
content = content.replace(b"\r\n", b"\n")
file_path.write_bytes(content)
def get_wheel_pattern(py_version: str, abi3: bool = False) -> str: def get_wheel_pattern(py_version: str, abi3: bool = False) -> str:
@@ -317,29 +319,38 @@ def main():
repo_root = Path(__file__).parent.parent repo_root = Path(__file__).parent.parent
dist_dir = repo_root / "dist" 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 must be LF (.gitattributes enforces this on checkout).
print(f"\n{'=' * 70}") print(f"\n{'=' * 70}")
print("Code generation from C headers (tools/generate.py)") print("Validating generated files (tools/generate.py --check)")
print(f"{'=' * 70}") print(f"{'=' * 70}")
if generate.main() != 0: if generate.main(check=True) != 0:
print("✗ Code generation failed", file=sys.stderr)
return 1 return 1
# Run ruff to check and fix any issues
print(f"\n{'=' * 70}") print(f"\n{'=' * 70}")
print("Linting and formatting") print("Lint and format validation")
print(f"{'=' * 70}") print(f"{'=' * 70}")
if not run_command(["uv", "run", "ruff", "check", "--fix", "."]): if not run_command(["uv", "run", "ruff", "check", "."]):
print("✗ Ruff check failed", file=sys.stderr) print("✗ Ruff check failed; fix and commit before releasing", file=sys.stderr)
return 1 return 1
# Run ruff format if not run_command(["uv", "run", "ruff", "format", "--check", "."]):
if not run_command(["uv", "run", "ruff", "format", "."]): print(
print("✗ Ruff format failed", file=sys.stderr) "✗ Ruff format check failed; run 'uv run ruff format .' and commit",
file=sys.stderr,
)
return 1 return 1
# Normalize all line endings to LF (important for consistent builds) if crlf_files := find_crlf_files(repo_root):
normalize_line_endings(repo_root) print("✗ CRLF line endings found in:", file=sys.stderr)
for p in crlf_files:
print(f" - {p.relative_to(repo_root)}", file=sys.stderr)
print(
"Convert to LF and commit (.gitattributes enforces eol=lf).",
file=sys.stderr,
)
return 1
# Get version from git repo # Get version from git repo
version = get_version_from_scm() version = get_version_from_scm()
@@ -347,6 +358,33 @@ def main():
return 1 return 1
is_release = is_release_version(version) 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 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,
text=True,
check=False,
).stdout.split()
if head_tags and not is_release:
print(
f"✗ HEAD is tagged ({', '.join(head_tags)}) but the version resolved "
f"to {version}, not a clean release.",
file=sys.stderr,
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
).stdout
if dirty.strip():
print("Working copy is dirty:", file=sys.stderr)
print(dirty, file=sys.stderr, end="")
print("Commit the changes, re-tag, and run again.", file=sys.stderr)
return 1
# Main header for the packaging process # Main header for the packaging process
print(f"\n{'=' * 70}") print(f"\n{'=' * 70}")
print( print(