Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ updates:
- "minor"
- "patch"

# ordvec-python's only runtime dep is numpy (floor >=2.0, matching abi3-py310 /
# ordvec-python's only runtime dep is numpy (floor >=2.2, matching abi3-py310 /
# Python >=3.10); maturin is the build dep. Group minor+patch into one PR;
# majors stay separate for manual review.
- package-ecosystem: "pip"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Details in [`docs/RANK_MODES.md`](docs/RANK_MODES.md).

```toml
[dependencies]
ordvec = "0.3"
ordvec = "0.4"

# Or, to track unreleased `main`, use a git dependency instead:
# ordvec = { git = "https://github.com/Fieldnote-Echo/ordvec" }
Expand Down
7 changes: 3 additions & 4 deletions cliff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ commit_parsers = [
]
protect_breaking_commits = true
filter_commits = false
# Only exact SemVer release tags count — the `^...$` anchors reject the repo's
# archive/* and backup/* tags AND pre-releases (e.g. v0.3.0-rc.1), matching the
# changelog workflow's stable-only trigger.
tag_pattern = '^v[0-9]+\.[0-9]+\.[0-9]+$'
# Only exact SemVer release tags count: each segment is either `0` or a
# non-zero-prefixed integer, matching `release.yml`'s strict guard.
tag_pattern = '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
topo_order = false
sort_commits = "newest"
1 change: 1 addition & 0 deletions ordvec-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "ordvec-ffi"
version = "0.4.0"
edition = "2021"
rust-version = "1.89"
publish = false
license = "MIT OR Apache-2.0"

Expand Down
219 changes: 210 additions & 9 deletions tests/release_publish_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
WORKFLOW_PATH = os.environ.get("RELEASE_WORKFLOW_PATH", ".github/workflows/release.yml")
PYTHON_WORKFLOW_PATH = os.environ.get("PYTHON_WORKFLOW_PATH", ".github/workflows/python.yml")
CI_WORKFLOW_PATH = os.environ.get("CI_WORKFLOW_PATH", ".github/workflows/ci.yml")
STRICT_STABLE_TAG_PATTERN = r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"
COVERAGE_WORKFLOW_PATH = os.environ.get("COVERAGE_WORKFLOW_PATH", ".github/workflows/coverage.yml")
SDE_ACTION_PATH = os.environ.get(
"SDE_ACTION_PATH", ".github/actions/setup-intel-sde/action.yml"
Expand Down Expand Up @@ -164,6 +165,7 @@ def split_inline_table(value: str) -> list[str]:
quote: str | None = None
escaped = False
bracket_depth = 0
brace_depth = 0
for index, char in enumerate(value):
if escaped:
escaped = False
Expand All @@ -183,7 +185,13 @@ def split_inline_table(value: str) -> list[str]:
if char == "]" and quote is None and bracket_depth > 0:
bracket_depth -= 1
continue
if char == "," and quote is None and bracket_depth == 0:
if char == "{" and quote is None:
brace_depth += 1
continue
if char == "}" and quote is None and brace_depth > 0:
brace_depth -= 1
continue
if char == "," and quote is None and bracket_depth == 0 and brace_depth == 0:
parts.append(value[start:index].strip())
start = index + 1
parts.append(value[start:].strip())
Expand Down Expand Up @@ -217,14 +225,22 @@ def parse_toml_value(value: str) -> Any:
def minimal_load_toml(path: str) -> dict[str, Any]:
data: dict[str, Any] = {}
current: dict[str, Any] = data
in_multiline_array = False
multiline_array: list[Any] | None = None
for lineno, raw_line in enumerate(read_text(path).splitlines(), start=1):
line = strip_toml_comment(raw_line).strip()
if not line:
continue
if in_multiline_array:
if line == "]" or line.endswith("]"):
in_multiline_array = False
if multiline_array is not None:
closes = line == "]" or (line.endswith("]") and line.count("]") > line.count("["))
if closes:
line = line[:-1].strip()
if line.endswith(","):
line = line[:-1].strip()
if line:
for part in split_inline_table(line):
multiline_array.append(parse_toml_value(part))
if closes:
multiline_array = None
Comment thread
Fieldnote-Echo marked this conversation as resolved.
continue
if line.startswith("[[") and line.endswith("]]"):
current = {}
Expand All @@ -240,15 +256,39 @@ def minimal_load_toml(path: str) -> dict[str, Any]:
if not separator:
raise ValueError(f"{path}:{lineno}: unsupported TOML line {line!r}")
if value.strip() == "[":
current[key.strip()] = []
in_multiline_array = True
multiline_array = []
current[key.strip()] = multiline_array
continue
current[key.strip()] = parse_toml_value(value)
if in_multiline_array:
if multiline_array is not None:
raise ValueError(f"{path}: unterminated multiline array")
return data


def read_toml_string_in_section(path: str, section: str, key: str) -> str:
current_section: str | None = None
for lineno, raw_line in enumerate(read_text(path).splitlines(), start=1):
line = strip_toml_comment(raw_line).strip()
if not line:
continue
if line.startswith("[") and line.endswith("]"):
if line.startswith("[["):
current_section = None
else:
current_section = line[1:-1].strip()
continue
if current_section != section:
continue
raw_key, separator, value = line.partition("=")
if not separator or raw_key.strip() != key:
continue
parsed = parse_toml_value(value)
if not isinstance(parsed, str):
raise ValueError(f"{path}:{lineno}: {section}.{key} must be a string")
return parsed
raise ValueError(f"{path}: missing {section}.{key}")


def load_toml(path: str) -> dict[str, Any]:
try:
if tomllib is None:
Expand All @@ -265,15 +305,36 @@ def load_toml(path: str) -> dict[str, Any]:
return data


def package_version(path: str) -> str:
def package_manifest(path: str) -> dict[str, Any]:
data = load_toml(path)
package = mapping(data.get("package"), f"{path}: package")
return package


def package_version(path: str) -> str:
package = package_manifest(path)
version = package.get("version")
if not isinstance(version, str) or not version:
fail(f"{path}: package.version must be a non-empty string")
return version


def package_rust_version(path: str) -> str:
package = package_manifest(path)
rust_version = package.get("rust-version")
if not isinstance(rust_version, str) or not rust_version:
fail(f"{path}: package.rust-version must be a non-empty string")
return rust_version


def package_publish_setting(path: str) -> bool:
package = package_manifest(path)
publish = package.get("publish", True)
if not isinstance(publish, bool):
fail(f"{path}: package.publish must be a boolean when present")
return publish


def project_version(path: str) -> str:
data = load_toml(path)
project = mapping(data.get("project"), f"{path}: project")
Expand All @@ -291,6 +352,13 @@ def python_init_version(path: str) -> str:
return matches[0]


def semver_minor_requirement(version: str) -> str:
match = re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version)
if match is None:
fail(f"package.version {version!r} is not a strict MAJOR.MINOR.PATCH SemVer")
return f"{match.group(1)}.{match.group(2)}"


def check_release_version_sync() -> None:
core_version = package_version("Cargo.toml")
expected = {
Expand Down Expand Up @@ -340,6 +408,135 @@ def check_release_version_sync() -> None:
fail(f"fuzz/Cargo.lock must lock the path dependency ordvec at {core_version}")


def check_release_compatibility_sync() -> None:
core_version = package_version("Cargo.toml")
core_msrv = package_rust_version("Cargo.toml")

for path in (
"ordvec-manifest/Cargo.toml",
"ordvec-python/Cargo.toml",
"ordvec-ffi/Cargo.toml",
):
rust_version = package_rust_version(path)
if rust_version != core_msrv:
fail(f"{path}: package.rust-version is {rust_version}, expected {core_msrv}")

readme = read_text("README.md")
minor_req = semver_minor_requirement(core_version)
quickstart = re.search(r"(?ms)^## Quickstart\b.*?```toml\n(?P<block>.*?)\n```", readme)
if quickstart is None:
fail("README.md must contain a Quickstart TOML dependency block")
if f'ordvec = "{minor_req}"' not in quickstart.group("block"):
fail(f"README.md quickstart must install ordvec = {minor_req!r}")
if f"MSRV-{core_msrv}-blue.svg" not in readme:
fail(f"README.md MSRV badge must mention {core_msrv}")
if f"ordvec's MSRV is **Rust {core_msrv}**" not in readme:
fail(f"README.md MSRV section must mention Rust {core_msrv}")

compatibility = read_text("docs/compatibility-policy.md")
if f"The Rust MSRV is Rust {core_msrv}." not in compatibility:
fail(f"docs/compatibility-policy.md must mention Rust {core_msrv}")

ci = read_text(".github/workflows/ci.yml")
msrv_toolchain = f"{core_msrv}.0"
if f"name: msrv ({msrv_toolchain})" not in ci:
fail(f".github/workflows/ci.yml MSRV job name must mention {msrv_toolchain}")
if f"toolchain: {msrv_toolchain}" not in ci:
fail(f".github/workflows/ci.yml MSRV job must pin toolchain {msrv_toolchain}")


def check_publication_model() -> None:
expected_publish = {
"Cargo.toml": True,
"ordvec-manifest/Cargo.toml": True,
"ordvec-python/Cargo.toml": False,
"ordvec-ffi/Cargo.toml": False,
"fuzz/Cargo.toml": False,
}
for path, expected in expected_publish.items():
actual = package_publish_setting(path)
if actual != expected:
wanted = "publishable" if expected else "publish = false"
got = "publishable" if actual else "publish = false"
fail(f"{path}: publication model is {got}, expected {wanted}")


def check_python_package_metadata() -> None:
pyproject = load_toml("ordvec-python/pyproject.toml")
project = mapping(pyproject.get("project"), "ordvec-python/pyproject.toml: project")
if project.get("name") != "ordvec":
fail("ordvec-python/pyproject.toml: project.name must be 'ordvec'")
if project.get("requires-python") != ">=3.10":
fail("ordvec-python/pyproject.toml: project.requires-python must be >=3.10")
dependencies = sequence(
project.get("dependencies"), "ordvec-python/pyproject.toml: project.dependencies"
)
if "numpy>=2.2" not in dependencies:
fail("ordvec-python/pyproject.toml: project.dependencies must include numpy>=2.2")

cargo = load_toml("ordvec-python/Cargo.toml")
dependencies_table = mapping(cargo.get("dependencies"), "ordvec-python/Cargo.toml: dependencies")
pyo3 = mapping(dependencies_table.get("pyo3"), "ordvec-python/Cargo.toml: dependencies.pyo3")
pyo3_features = sequence(
pyo3.get("features"), "ordvec-python/Cargo.toml: dependencies.pyo3.features"
)
for feature in ("extension-module", "abi3-py310"):
if feature not in pyo3_features:
fail(f"ordvec-python/Cargo.toml: pyo3 features must include {feature}")

readme = read_text("README.md")
py_readme = read_text("ordvec-python/README.md")
for path, text in (("README.md", readme), ("ordvec-python/README.md", py_readme)):
if "CPython 3.10+ (abi3)" not in text:
fail(f"{path}: Python install docs must mention CPython 3.10+ (abi3)")
if "`numpy>=2.2`" not in text:
fail(f"{path}: Python install docs must mention numpy>=2.2")

dependabot = read_text(".github/dependabot.yml")
if "floor >=2.2" not in dependabot:
fail(".github/dependabot.yml must keep the Python NumPy floor comment at >=2.2")


def check_strict_release_tag_patterns(workflow: dict[str, Any], path: str) -> None:
try:
tag_pattern = read_toml_string_in_section("cliff.toml", "git", "tag_pattern")
except ValueError as exc:
fail(str(exc))
if tag_pattern != STRICT_STABLE_TAG_PATTERN:
fail(
"cliff.toml: git.tag_pattern must match release.yml's strict stable "
"SemVer guard"
)

jobs = mapping(workflow.get("jobs"), f"{path}: jobs")
guard = mapping(jobs.get("guard"), f"{path}: jobs.guard")
steps = sequence(guard.get("steps"), f"{path}: jobs.guard.steps")
semver_runs: list[str] = []
for index, raw_step in enumerate(steps):
step = mapping(raw_step, f"{path}: jobs.guard.steps[{index}]")
if step.get("id") != "semver":
continue
run = step.get("run")
if not isinstance(run, str):
fail(f"{path}: jobs.guard semver step must be a run step")
semver_runs.append(run)
if len(semver_runs) != 1:
fail(f"{path}: jobs.guard must contain exactly one id: semver step")
assignment = f"semver='{STRICT_STABLE_TAG_PATTERN}'"
if assignment not in shell_logical_lines(semver_runs[0]):
fail(f"{path}: jobs.guard semver step must execute the strict stable SemVer regex")

compiled = re.compile(STRICT_STABLE_TAG_PATTERN)
accepted = ("v0.4.0", "v1.2.3", "v10.20.30")
rejected = ("v01.2.3", "v1.02.3", "v1.2.03", "v1.2.3-rc.1", "archive/v1.2.3")
for tag in accepted:
if compiled.fullmatch(tag) is None:
fail(f"strict release tag regex must accept {tag}")
for tag in rejected:
if compiled.fullmatch(tag) is not None:
fail(f"strict release tag regex must reject {tag}")


def shell_vars(name: str) -> set[str]:
return {f"${name}", f"${{{name}}}"}

Expand Down Expand Up @@ -987,6 +1184,10 @@ def check_sde_cache_invariants() -> None:
def main() -> None:
workflow = load_workflow(WORKFLOW_PATH)
check_release_version_sync()
check_release_compatibility_sync()
check_publication_model()
check_python_package_metadata()
check_strict_release_tag_patterns(workflow, WORKFLOW_PATH)
check_hash_requirement_temp_paths(
[WORKFLOW_PATH, PYTHON_WORKFLOW_PATH, CI_WORKFLOW_PATH, COVERAGE_WORKFLOW_PATH]
)
Expand Down
Loading