From b908f696bc2967241503a7497d9d750aaf73141e Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Mon, 26 Oct 2026 12:04:57 +0900 Subject: [PATCH 1/2] release: strip the wheel Name before comparing it, so a CRLF METADATA from Windows passes The METADATA of a wheel built on Windows has CRLF line endings, so the Name value read by the release guards ended in a carriage return. In 26.11.1.dev0 the native publish guard stopped on "arcadedb-26.11.1.dev0-py3-none-win_amd64.whl: file name says arcadedb, METADATA says arcadedb", and the JVM wheel check stopped on "the wheels disagree on distribution name and import package: [('arcadedb-jvm', ...), ('arcadedb-jvm\r', ...)]". Nothing reached PyPI. The three places that read the Name (release-native.yml publish guard, release-jvm.yml wheel check and publish guard) now strip the value and the PEP 503 normaliser strips too. A name that differs in anything else is still a hard refusal, and the refusal message shows both names with repr so an invisible difference is visible next time. tests/test_release_name_guards.py runs the guard code taken from the workflow files on synthetic wheels: LF, CRLF, trailing blanks and spelling variants pass; another distribution in the file name or the METADATA, one wrong wheel among right ones and a native wheel in the JVM workflow (and the reverse) are refused; the JVM wheel check passes 16 wheels whose Windows METADATA is CRLF. On the old workflows 9 of its 21 tests fail with the production error. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release-jvm.yml | 14 ++- .github/workflows/release-native.yml | 10 +- tests/test_release_name_guards.py | 179 +++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 tests/test_release_name_guards.py diff --git a/.github/workflows/release-jvm.yml b/.github/workflows/release-jvm.yml index cd681c3..c275cc4 100644 --- a/.github/workflows/release-jvm.yml +++ b/.github/workflows/release-jvm.yml @@ -204,7 +204,8 @@ jobs: with zipfile.ZipFile(w) as zf: meta = next(n for n in zf.namelist() if n.endswith(".dist-info/METADATA")) top = next(n for n in zf.namelist() if n.endswith(".dist-info/top_level.txt")) - dist = re.search(r"^Name: (.+)$", zf.read(meta).decode(), re.M).group(1) + # strip: the METADATA of a wheel built on Windows has CRLF line endings, so the value ends in a carriage return + dist = re.search(r"^Name: (.+)$", zf.read(meta).decode(), re.M).group(1).strip() imports = zf.read(top).decode().split() names.add((re.sub(r"[-_.]+", "-", dist).lower(), tuple(imports))) if len(names) != 1: @@ -259,14 +260,17 @@ jobs: # GUARD: this workflow publishes the distribution `arcadedb-jvm` and nothing else. A wheel with # another name stops the job here, before anything reaches PyPI. Two checks, so neither can drift alone: - # the wheel file name and the Name field of its METADATA, both must be DIST_NAME. + # the wheel file name and the Name field of its METADATA, both must be DIST_NAME. Both names are stripped of whitespace and + # normalised as PEP 503 does before they are compared: the METADATA of a wheel built on Windows has CRLF line endings, so its Name + # value ends in a carriage return (26.11.1.dev0 stopped here). A name that differs in anything else is still a hard refusal + # (tests/test_release_name_guards.py). - name: Refuse unless every wheel is the distribution ${{ env.DIST_NAME }} run: | python3 -I - <<'PY' import os, pathlib, re, sys, zipfile want = os.environ["DIST_NAME"] - norm = lambda s: re.sub(r"[-_.]+", "-", s).lower() + norm = lambda s: re.sub(r"[-_.]+", "-", s.strip()).lower() wheels = sorted(pathlib.Path("dist").glob("*.whl")) if not wheels: sys.exit("no wheels in dist/") @@ -275,9 +279,9 @@ jobs: file_dist = w.name.split("-")[0] with zipfile.ZipFile(w) as zf: meta = next(n for n in zf.namelist() if n.endswith(".dist-info/METADATA")) - meta_dist = re.search(r"^Name: (.+)$", zf.read(meta).decode(), re.M).group(1) + meta_dist = re.search(r"^Name: (.+)$", zf.read(meta).decode(), re.M).group(1).strip() if norm(file_dist) != want or norm(meta_dist) != want: - bad.append(f"{w.name}: file name says {file_dist}, METADATA says {meta_dist}") + bad.append(f"{w.name}: file name says {file_dist!r}, METADATA says {meta_dist!r}") if bad: print("\n".join(f"::error::{b}" for b in bad)) sys.exit(f"Refusing to publish: this workflow publishes only the distribution '{want}'.") diff --git a/.github/workflows/release-native.yml b/.github/workflows/release-native.yml index aa90181..7d24ad5 100644 --- a/.github/workflows/release-native.yml +++ b/.github/workflows/release-native.yml @@ -188,14 +188,16 @@ jobs: # GUARD: this workflow publishes the distribution `arcadedb` and nothing else. A wheel with another name stops the job here, # before anything reaches PyPI. Two checks, so neither can drift alone: the wheel file name and the Name field of its METADATA, - # both must be DIST_NAME. + # both must be DIST_NAME. Both names are stripped of whitespace and normalised as PEP 503 does before they are compared: the + # METADATA of a wheel built on Windows has CRLF line endings, so its Name value ends in a carriage return (26.11.1.dev0 stopped here). + # A name that differs in anything else is still a hard refusal (tests/test_release_name_guards.py). - name: Refuse unless every wheel is the distribution ${{ env.DIST_NAME }} run: | python3 -I - <<'PY' import os, pathlib, re, sys, zipfile want = os.environ["DIST_NAME"] - norm = lambda s: re.sub(r"[-_.]+", "-", s).lower() + norm = lambda s: re.sub(r"[-_.]+", "-", s.strip()).lower() wheels = sorted(pathlib.Path("dist").glob("*.whl")) if not wheels: sys.exit("no wheels in dist/") @@ -204,9 +206,9 @@ jobs: file_dist = w.name.split("-")[0] with zipfile.ZipFile(w) as zf: meta = next(n for n in zf.namelist() if n.endswith(".dist-info/METADATA")) - meta_dist = re.search(r"^Name: (.+)$", zf.read(meta).decode(), re.M).group(1) + meta_dist = re.search(r"^Name: (.+)$", zf.read(meta).decode(), re.M).group(1).strip() if norm(file_dist) != want or norm(meta_dist) != want: - bad.append(f"{w.name}: file name says {file_dist}, METADATA says {meta_dist}") + bad.append(f"{w.name}: file name says {file_dist!r}, METADATA says {meta_dist!r}") if bad: print("\n".join(f"::error::{b}" for b in bad)) sys.exit(f"Refusing to publish: this workflow publishes only the distribution '{want}'.") diff --git a/tests/test_release_name_guards.py b/tests/test_release_name_guards.py new file mode 100644 index 0000000..9d407ed --- /dev/null +++ b/tests/test_release_name_guards.py @@ -0,0 +1,179 @@ +"""The distribution-name guards of the release workflows: whitespace and spelling variants pass, another distribution is refused. + +The guards are Python in a heredoc inside release-native.yml and release-jvm.yml. These tests pull that code out of the workflow +files and run it on small synthetic wheels, so the code that ships is the code that is tested. The METADATA of a wheel built on +Windows has CRLF line endings, which left a carriage return on the Name value and stopped 26.11.1.dev0 before anything was uploaded. +""" + +from __future__ import annotations + +import os +import re +import subprocess # nosec B404 - test-controlled child processes, no untrusted input +import sys +import textwrap +import zipfile +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +WORKFLOWS = REPO / ".github" / "workflows" + +GUARD_STEP = "- name: Refuse unless every wheel is the distribution" +VERIFY_STEP = "- name: Verify wheel count, versions and sizes" +TAG = "26.11.1.dev0" + +# (workflow file, distribution it publishes) +PUBLISHERS = [ + ("release-native.yml", "arcadedb"), + ("release-jvm.yml", "arcadedb-jvm"), +] + + +def _script(workflow: str, step: str) -> str: + """The python3 heredoc of the first step called `step` in the workflow file, dedented.""" + lines = (WORKFLOWS / workflow).read_text(encoding="utf-8").splitlines() + start = next(i for i, line in enumerate(lines) if line.strip().startswith(step)) + begin = next(i for i in range(start, len(lines)) if "python3 -I - <<'PY'" in lines[i]) + end = next(i for i in range(begin + 1, len(lines)) if lines[i].strip() == "PY") + return textwrap.dedent("\n".join(lines[begin + 1 : end])) + "\n" + + +def _wheel( + directory: Path, + file_name: str, + meta_name: str, + newline: str = "\n", + top_level: str | None = None, +) -> Path: + """A wheel with a METADATA (and a top_level.txt when given) written with the line ending `newline`.""" + dist = file_name.split("-")[0] + path = directory / f"{file_name}.whl" + meta = newline.join( + ["Metadata-Version: 2.4", f"Name: {meta_name}", f"Version: {TAG}", "Summary: x", ""] + ) + with zipfile.ZipFile(path, "w") as zf: + zf.writestr(f"{dist}-{TAG}.dist-info/METADATA", meta) + if top_level is not None: + zf.writestr(f"{dist}-{TAG}.dist-info/top_level.txt", top_level + newline) + return path + + +def _run(script: str, cwd: Path, **env: str) -> subprocess.CompletedProcess: + return subprocess.run( # nosec B603 - the interpreter running the tests, the script comes from this repo + [sys.executable, "-I", "-"], + input=script, + cwd=cwd, + env={**os.environ, **env}, + capture_output=True, + text=True, + check=False, + ) + + +def _guard(workflow: str, dist: str, tmp_path: Path, wheels) -> subprocess.CompletedProcess: + (tmp_path / "dist").mkdir() + for file_name, meta_name, newline in wheels: + _wheel(tmp_path / "dist", file_name, meta_name, newline) + return _run(_script(workflow, GUARD_STEP), tmp_path, DIST_NAME=dist) + + +@pytest.mark.parametrize("workflow, dist", PUBLISHERS) +@pytest.mark.parametrize( + "newline, suffix", + [("\n", ""), ("\r\n", ""), ("\n", " "), ("\r\n", " \t")], + ids=["lf", "crlf", "trailing-spaces", "crlf-and-tab"], +) +def test_the_right_distribution_passes_whatever_ends_the_line(workflow, dist, newline, suffix, tmp_path): + file_name = f"{dist.replace('-', '_')}-{TAG}-py3-none-any" + result = _guard(workflow, dist, tmp_path, [(file_name, dist + suffix, newline)]) + assert result.returncode == 0, result.stdout + result.stderr + assert f"all distribution '{dist}'" in result.stdout + + +@pytest.mark.parametrize("workflow, dist", PUBLISHERS) +def test_a_pep_503_spelling_of_the_name_passes(workflow, dist, tmp_path): + spelled = dist.upper().replace("-", "_") + file_name = f"{dist.replace('-', '_')}-{TAG}-py3-none-any" + result = _guard(workflow, dist, tmp_path, [(file_name, spelled, "\r\n")]) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("workflow, dist", PUBLISHERS) +@pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["lf", "crlf"]) +def test_another_name_in_the_metadata_is_refused(workflow, dist, newline, tmp_path): + file_name = f"{dist.replace('-', '_')}-{TAG}-py3-none-any" + result = _guard(workflow, dist, tmp_path, [(file_name, "arcadedb-embedded", newline)]) + assert result.returncode != 0 + assert "Refusing to publish" in result.stdout + result.stderr + + +@pytest.mark.parametrize("workflow, dist", PUBLISHERS) +def test_another_name_in_the_file_name_is_refused(workflow, dist, tmp_path): + result = _guard(workflow, dist, tmp_path, [(f"arcadedb_embedded-{TAG}-py3-none-any", dist, "\r\n")]) + assert result.returncode != 0 + assert "Refusing to publish" in result.stdout + result.stderr + + +@pytest.mark.parametrize("workflow, dist", PUBLISHERS) +def test_one_wrong_wheel_among_right_ones_is_refused(workflow, dist, tmp_path): + right = f"{dist.replace('-', '_')}-{TAG}" + wheels = [ + (f"{right}-py3-none-manylinux_2_28_x86_64", dist, "\n"), + (f"{right}-py3-none-win_amd64", dist, "\r\n"), + (f"{right}-py3-none-macosx_11_0_arm64", "arcadedb-embedded", "\n"), + ] + result = _guard(workflow, dist, tmp_path, wheels) + assert result.returncode != 0 + assert "macosx_11_0_arm64" in result.stdout + + +def test_the_native_workflow_refuses_a_jvm_wheel_and_the_other_way_round(tmp_path): + native = tmp_path / "native" + native.mkdir() + jvm = tmp_path / "jvm" + jvm.mkdir() + as_jvm = _guard("release-native.yml", "arcadedb", native, [(f"arcadedb_jvm-{TAG}-py3-none-any", "arcadedb-jvm", "\n")]) + as_native = _guard("release-jvm.yml", "arcadedb-jvm", jvm, [(f"arcadedb-{TAG}-py3-none-any", "arcadedb", "\n")]) + assert as_jvm.returncode != 0 + assert as_native.returncode != 0 + + +def _jvm_release_tree(root: Path, windows_metadata_name: str | None = None) -> Path: + """What the JVM "Verify wheel count, versions and sizes" step reads: pyproject.toml, a size script and 16 wheels.""" + (root / "scripts").mkdir() + (root / "scripts" / "verify_wheel_size.py").write_text("import sys\nsys.exit(0)\n", encoding="utf-8") + (root / "pyproject.toml").write_bytes((REPO / "pyproject.toml").read_bytes()) + dist = root / "dist" + dist.mkdir() + pythons = re.findall(r"Programming Language :: Python :: (3\.\d+)", (REPO / "pyproject.toml").read_text(encoding="utf-8")) + platforms = { + "manylinux_2_28_x86_64": "\n", + "manylinux_2_28_aarch64": "\n", + "macosx_11_0_arm64": "\n", + "win_amd64": "\r\n", # built on Windows: CRLF, as in 26.11.1.dev0 + } + for py in pythons: + tag = "cp" + py.replace(".", "") + for platform, newline in platforms.items(): + name = windows_metadata_name if (platform == "win_amd64" and windows_metadata_name) else "arcadedb-jvm" + _wheel(dist, f"arcadedb_jvm-{TAG}-{tag}-{tag}-{platform}", name, newline, top_level="arcadedb") + return root + + +def test_the_jvm_wheel_check_accepts_crlf_metadata_from_the_windows_wheels(tmp_path): + root = _jvm_release_tree(tmp_path) + output = tmp_path / "github_output" + result = _run(_script("release-jvm.yml", VERIFY_STEP), root, TAG=TAG, GITHUB_OUTPUT=str(output)) + assert result.returncode == 0, result.stdout + result.stderr + assert "distribution arcadedb-jvm, import package arcadedb" in result.stdout + assert "import-name=arcadedb" in output.read_text(encoding="utf-8") + + +def test_the_jvm_wheel_check_still_refuses_wheels_of_two_distributions(tmp_path): + root = _jvm_release_tree(tmp_path, windows_metadata_name="arcadedb-embedded") + output = tmp_path / "github_output" + result = _run(_script("release-jvm.yml", VERIFY_STEP), root, TAG=TAG, GITHUB_OUTPUT=str(output)) + assert result.returncode != 0 + assert "disagree on distribution name" in result.stdout From 46262b99697e76d9d5cb96b7592a47396e633226 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Mon, 26 Oct 2026 12:28:15 +0900 Subject: [PATCH 2/2] check_no_embedded_name: allow the three Migrate steps and the guard test's data names The old-name check failed on main since #84 (the three steps of "Migrate from the old names" in which-package.adoc must name the old packages; docs-only changes skip the test run that would have shown it) and on the new tests/test_release_name_guards.py (a guard test needs another distribution name as data). The migration steps are allowed by one phrase each (pip uninstall, "if you used", "both become"), not by the whole page. The test names the other distribution in two constants that carry the allow-old-name marker. The check itself is not weakened: any other line that names the old package still fails. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_no_embedded_name.py | 9 ++++++++- tests/test_release_name_guards.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/check_no_embedded_name.py b/scripts/check_no_embedded_name.py index 65ba0c8..61eb8df 100644 --- a/scripts/check_no_embedded_name.py +++ b/scripts/check_no_embedded_name.py @@ -19,6 +19,9 @@ point at real threads, so they keep their text. * The sentence "This package was published as arcadedb-embedded until 26.10.x; install arcadedb or arcadedb-jvm now." and lines that say "formerly arcadedb-embedded". +* The three steps of "Migrate from the old names" (docs/modules/ROOT/pages/which-package.adoc): they must name the old + packages to tell a reader what to uninstall, what they used and which import to change. Each is allowed by one phrase of its + step, not by the whole page. * Any line that carries the marker `allow-old-name`, for the rare mention that needs it. usage: check_no_embedded_name.py [--root DIR] @@ -60,7 +63,11 @@ ALLOWED_LINES = re.compile( r"allow-old-name" r"|This package was published as arcadedb-embedded until 26\.10\.x; install arcadedb now\." - r"|formerly `?arcadedb[_-]embedded", + r"|formerly `?arcadedb[_-]embedded" + # The "Migrate from the old names" steps must name the old packages (one phrase per step): + r"|pip uninstall arcadedb[_-]embedded" # step 1, what to remove + r"|if you used `arcadedb[_-]embedded" # step 2, which new package replaces which old one + r"|`import arcadedb_embedded` and `import arcadedb_native` both become", # step 3, the import change re.IGNORECASE, ) diff --git a/tests/test_release_name_guards.py b/tests/test_release_name_guards.py index 9d407ed..6d4c88a 100644 --- a/tests/test_release_name_guards.py +++ b/tests/test_release_name_guards.py @@ -24,6 +24,10 @@ VERIFY_STEP = "- name: Verify wheel count, versions and sizes" TAG = "26.11.1.dev0" +# Another distribution, as test data: the guards must refuse it (allow-old-name: scripts/check_no_embedded_name.py lets a guard test name it) +OTHER_DIST = "arcadedb-embedded" # allow-old-name +OTHER_FILE_STEM = "arcadedb_embedded" # allow-old-name + # (workflow file, distribution it publishes) PUBLISHERS = [ ("release-native.yml", "arcadedb"), @@ -104,14 +108,14 @@ def test_a_pep_503_spelling_of_the_name_passes(workflow, dist, tmp_path): @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["lf", "crlf"]) def test_another_name_in_the_metadata_is_refused(workflow, dist, newline, tmp_path): file_name = f"{dist.replace('-', '_')}-{TAG}-py3-none-any" - result = _guard(workflow, dist, tmp_path, [(file_name, "arcadedb-embedded", newline)]) + result = _guard(workflow, dist, tmp_path, [(file_name, OTHER_DIST, newline)]) assert result.returncode != 0 assert "Refusing to publish" in result.stdout + result.stderr @pytest.mark.parametrize("workflow, dist", PUBLISHERS) def test_another_name_in_the_file_name_is_refused(workflow, dist, tmp_path): - result = _guard(workflow, dist, tmp_path, [(f"arcadedb_embedded-{TAG}-py3-none-any", dist, "\r\n")]) + result = _guard(workflow, dist, tmp_path, [(f"{OTHER_FILE_STEM}-{TAG}-py3-none-any", dist, "\r\n")]) assert result.returncode != 0 assert "Refusing to publish" in result.stdout + result.stderr @@ -122,7 +126,7 @@ def test_one_wrong_wheel_among_right_ones_is_refused(workflow, dist, tmp_path): wheels = [ (f"{right}-py3-none-manylinux_2_28_x86_64", dist, "\n"), (f"{right}-py3-none-win_amd64", dist, "\r\n"), - (f"{right}-py3-none-macosx_11_0_arm64", "arcadedb-embedded", "\n"), + (f"{right}-py3-none-macosx_11_0_arm64", OTHER_DIST, "\n"), ] result = _guard(workflow, dist, tmp_path, wheels) assert result.returncode != 0 @@ -172,7 +176,7 @@ def test_the_jvm_wheel_check_accepts_crlf_metadata_from_the_windows_wheels(tmp_p def test_the_jvm_wheel_check_still_refuses_wheels_of_two_distributions(tmp_path): - root = _jvm_release_tree(tmp_path, windows_metadata_name="arcadedb-embedded") + root = _jvm_release_tree(tmp_path, windows_metadata_name=OTHER_DIST) output = tmp_path / "github_output" result = _run(_script("release-jvm.yml", VERIFY_STEP), root, TAG=TAG, GITHUB_OUTPUT=str(output)) assert result.returncode != 0