diff --git a/docs/version-policy.md b/docs/version-policy.md index 4cb9ab2..e367aa7 100644 --- a/docs/version-policy.md +++ b/docs/version-policy.md @@ -49,6 +49,31 @@ and changes only configured version values: `0.1.1-rc.1` becomes `0.1.1`, withou another base increment. Existing release review and publication authority still apply. The updater never creates a tag, release, publication, or deployment. +A stable baseline enters the release-candidate channel only when the PR body has +a line exactly `Release-Channel: rc`: `0.8.3` becomes `0.8.4-rc.1` (`0.8.4rc1` +in Python metadata) for a patch, using the normal largest-increment rule. The +marker is rejected on a baseline that is already a prerelease, which keeps its +channel (`0.8.4-rc.1` becomes `0.8.5-rc.1`) and is promoted only by the exact +promotion title above. Release tags must equal `v` plus the PEP 440 package +version (`v0.8.4rc1`, `v0.8.4`); the Release workflow rejects any mismatch. + +A stable baseline whose product change already carries its bump (so it was +merged but never published) enters the candidate channel without a second +increment only when the PR body has a line exactly `Release-Channel: rc-same-base`: +`0.9.3` becomes `0.9.3-rc.1` (`0.9.3rc1` in Python metadata). The case is +metadata-only: after stripping the configured version values, every changed path +must be a non-release path (documentation, tests, CI, version scripts); any +remaining product difference from the base is rejected. It needs a stable +baseline, is mutually exclusive with `Release-Channel: rc`, is ignored by the +promotion title, and never writes a tag, release, or publication. Tag the merged +main tip as `v0.9.3rc1`; the same version is later promoted by the exact +promotion title (`0.9.3rc1` becomes `0.9.3`) with no base increment. + +To prove the candidate artifacts, run +`python3 scripts/check-rc-artifact-identity.py --base-ref REF`. It builds the base +commit and the candidate with `uv build` and fails unless the wheel and sdist +differ only in version metadata and renamed files. + For a new versioned component, run the toolkit bootstrap again or run `python3 scripts/mw-version.py enroll --repo PATH --unit COMPONENT_PATH` from an installed copy. Review the generated configuration and add any runtime/plugin diff --git a/pyproject.toml b/pyproject.toml index 0dbc947..2ca5f3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fava-trails" -version = "0.9.3" +version = "0.9.3rc1" description = "FAVA Trails 🫛👣 — Federated Agents Versioned Audit Trail. VCS-backed memory for AI agents via MCP." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/check-rc-artifact-identity.py b/scripts/check-rc-artifact-identity.py new file mode 100644 index 0000000..0ca42f8 --- /dev/null +++ b/scripts/check-rc-artifact-identity.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Prove a same-base release candidate differs from its base only in version metadata. + +Builds the wheel and sdist of ``--base-ref`` and of ``--candidate-ref`` (default +HEAD) with ``uv build`` from clean ``git archive`` trees, then compares them. The +candidate must carry the same members as the base, with identical bytes once the +candidate version (``0.9.3rc1``) is rewritten to the base version (``0.9.3``). +Wheel ``RECORD`` is ignored because it hashes the version-bearing files. Prints a +JSON report (versions, file names, SHA-256) and exits non-zero on any difference. +It never tags, publishes, installs, or touches a network index beyond what +``uv build`` needs. Standard library plus ``uv`` and ``git``. +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import zipfile +from pathlib import Path + +IGNORED_WHEEL_MEMBERS = ("RECORD",) + + +def _normalise(data: bytes, candidate: str, base: str) -> bytes: + return data.replace(candidate.encode(), base.encode()) + + +def _wheel_members(path: Path) -> dict[str, bytes]: + with zipfile.ZipFile(path) as zf: + return { + n: zf.read(n) + for n in zf.namelist() + if not n.endswith("/") and n.rsplit("/", 1)[-1] not in IGNORED_WHEEL_MEMBERS + } + + +def _sdist_members(path: Path) -> dict[str, bytes]: + out: dict[str, bytes] = {} + with tarfile.open(path, "r:gz") as tf: + for member in tf.getmembers(): + if member.isfile(): + out[member.name] = tf.extractfile(member).read() # type: ignore[union-attr] + return out + + +def _compare_members( + label: str, base: dict[str, bytes], cand: dict[str, bytes], base_version: str, cand_version: str +) -> list[str]: + problems: list[str] = [] + cand_by_base_name = {n.replace(cand_version, base_version): n for n in cand} + for name in sorted(set(base) - set(cand_by_base_name)): + problems.append(f"{label}: missing from candidate: {name}") + for name in sorted(set(cand_by_base_name) - set(base)): + problems.append(f"{label}: unexpected in candidate: {cand_by_base_name[name]}") + differing = 0 + for name in sorted(set(base) & set(cand_by_base_name)): + got = _normalise(cand[cand_by_base_name[name]], cand_version, base_version) + if got != base[name]: + problems.append(f"{label}: non-version bytes differ: {name}") + elif cand[cand_by_base_name[name]] != base[name]: + differing += 1 + if not differing: + problems.append(f"{label}: candidate carries no version change; it is not a candidate") + return problems + + +def _single(directory: Path, pattern: str) -> Path: + found = sorted(directory.glob(pattern)) + if len(found) != 1: + raise ValueError(f"expected exactly one {pattern} in {directory}, found {len(found)}") + return found[0] + + +def compare_artifacts(base_dir: Path, cand_dir: Path, base_version: str, cand_version: str) -> list[str]: + """Return a list of problems; empty means the candidate is metadata-only.""" + problems: list[str] = [] + if base_version == cand_version: + return [f"candidate version equals base version {base_version}"] + try: + base_wheel, cand_wheel = _single(base_dir, "*.whl"), _single(cand_dir, "*.whl") + base_sdist, cand_sdist = _single(base_dir, "*.tar.gz"), _single(cand_dir, "*.tar.gz") + except ValueError as e: + return [str(e)] + for base_file, cand_file in ((base_wheel, cand_wheel), (base_sdist, cand_sdist)): + if cand_file.name != base_file.name.replace(base_version, cand_version): + problems.append(f"artifact name {cand_file.name} does not carry version {cand_version}") + problems += _compare_members( + "wheel", _wheel_members(base_wheel), _wheel_members(cand_wheel), base_version, cand_version + ) + problems += _compare_members( + "sdist", _sdist_members(base_sdist), _sdist_members(cand_sdist), base_version, cand_version + ) + return problems + + +def _git_archive(repo: Path, ref: str, dest: Path) -> None: + data = subprocess.run( + ["git", "-C", str(repo), "archive", "--format=tar", ref], capture_output=True, check=True + ).stdout + with tarfile.open(fileobj=io.BytesIO(data)) as tf: + tf.extractall(dest, filter="data") + + +def _build(repo: Path, ref: str, work: Path) -> tuple[Path, str]: + tree = work / "tree" + tree.mkdir() + _git_archive(repo, ref, tree) + version = tomllib.loads((tree / "pyproject.toml").read_text())["project"]["version"] + out = work / "dist" + subprocess.run(["uv", "build", "--out-dir", str(out)], cwd=tree, check=True, capture_output=True) + return out, version + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _hashes(directory: Path) -> dict[str, str]: + return {p.name: _sha256(p) for p in sorted(directory.iterdir()) if p.suffix == ".whl" or p.name.endswith(".tar.gz")} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-ref", required=True, help="commit whose product bytes the candidate must keep") + parser.add_argument("--candidate-ref", default="HEAD") + parser.add_argument("--repo", default=".") + args = parser.parse_args(argv) + repo = Path(args.repo).resolve() + with tempfile.TemporaryDirectory(prefix="rc-identity.") as tmp: + roots = {} + for name, ref in (("base", args.base_ref), ("candidate", args.candidate_ref)): + work = Path(tmp) / name + work.mkdir() + roots[name] = _build(repo, ref, work) + (base_dir, base_version), (cand_dir, cand_version) = roots["base"], roots["candidate"] + problems = compare_artifacts(base_dir, cand_dir, base_version, cand_version) + report = { + "base_ref": args.base_ref, + "candidate_ref": args.candidate_ref, + "base_version": base_version, + "candidate_version": cand_version, + "candidate_artifacts": _hashes(cand_dir), + "base_artifacts": _hashes(base_dir), + "problems": problems, + } + print(json.dumps(report, indent=2)) + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/mw-version.py b/scripts/mw-version.py index 7fa4ba7..b014014 100644 --- a/scripts/mw-version.py +++ b/scripts/mw-version.py @@ -113,6 +113,18 @@ promotion title above. Release tags must equal `v` plus the PEP 440 package version (`v0.8.4rc1`, `v0.8.4`); the Release workflow rejects any mismatch. +A stable baseline whose product change already carries its bump (so it was +merged but never published) enters the candidate channel without a second +increment only when the PR body has a line exactly `Release-Channel: rc-same-base`: +`0.9.3` becomes `0.9.3-rc.1` (`0.9.3rc1` in Python metadata). The case is +metadata-only: after stripping the configured version values, every changed path +must be a non-release path (documentation, tests, CI, version scripts); any +remaining product difference from the base is rejected. It needs a stable +baseline, is mutually exclusive with `Release-Channel: rc`, is ignored by the +promotion title, and never writes a tag, release, or publication. Tag the merged +main tip as `v0.9.3rc1`; the same version is later promoted by the exact +promotion title (`0.9.3rc1` becomes `0.9.3`) with no base increment. + For a new versioned component, run the toolkit bootstrap again or run `python3 scripts/mw-version.py enroll --repo PATH --unit COMPONENT_PATH` from an installed copy. Review the generated configuration and add any runtime/plugin @@ -122,6 +134,7 @@ START_RC_MARKER = re.compile(r"^Release-Channel: rc[ \t]*$", re.MULTILINE) +SAME_BASE_RC_MARKER = re.compile(r"^Release-Channel: rc-same-base[ \t]*$", re.MULTILINE) class PolicyError(ValueError): @@ -381,6 +394,9 @@ def evaluate(root: Path, args) -> list[tuple[Path, str]]: level = intent(title, body, [c.strip() for c in commits if c.strip()]) promote = title == "chore(release): promote version" start_rc = bool(START_RC_MARKER.search(body)) and not promote + same_base = bool(SAME_BASE_RC_MARKER.search(body)) and not promote + if start_rc and same_base: + raise PolicyError("Release-Channel markers rc and rc-same-base are mutually exclusive") raw_changes = set(git(root, "diff", "--name-only", "--no-renames", base).splitlines()) raw_changes.update(git(root, "ls-files", "--others", "--exclude-standard").splitlines()) changes = {p for p in raw_changes if not any(fnmatch.fnmatchcase(p, pat) for pat in config.get("exclude_paths", []))} @@ -393,6 +409,13 @@ def evaluate(root: Path, args) -> list[tuple[Path, str]]: if not promote: changes = substantive_changes(root, base, changes, configured_files(root, units)) changes = release_changes(changes, config) + if same_base and changes: + # Version values were already stripped by substantive_changes, so anything left is a + # real difference from the base: the candidate must carry the base's exact product bytes. + raise PolicyError( + "a same-base release candidate may change configured version values only; " + "product changes: " + ", ".join(sorted(changes)) + ) all_patterns = [p for u in units for p in u["paths"]] shared = any(not any(fnmatch.fnmatchcase(p, pat) for pat in all_patterns) for p in changes) today = dt.datetime.now(ZoneInfo("America/Toronto")).date() @@ -426,7 +449,13 @@ def evaluate(root: Path, args) -> list[tuple[Path, str]]: target = baseline if promote and not raw_changes: affected = bool(baseline[3]) - if affected: + if same_base: + if baseline[3]: + raise PolicyError("a same-base release candidate requires a stable baseline; the prerelease channel is already set") + # Same base, no increment: 0.9.3 becomes 0.9.3-rc.1 (0.9.3rc1 in Python metadata). + affected = True + target = (*baseline[:3], "rc.1", None) + elif affected: if promote: if not baseline[3]: raise PolicyError("promotion requires a prerelease baseline") diff --git a/src/fava_trails/__init__.py b/src/fava_trails/__init__.py index e6fe45d..26a0638 100644 --- a/src/fava_trails/__init__.py +++ b/src/fava_trails/__init__.py @@ -10,4 +10,4 @@ try: __version__ = version("fava-trails") except PackageNotFoundError: # pragma: no cover - source tree without install metadata - __version__ = "0.9.3" + __version__ = "0.9.3rc1" diff --git a/tests/test_release_channels.py b/tests/test_release_channels.py index 795c37f..3184844 100644 --- a/tests/test_release_channels.py +++ b/tests/test_release_channels.py @@ -279,3 +279,287 @@ def test_promotion_of_stable_baseline_rejected(tmp_path): _git(repo, "commit", "-qam", "chore: revert change") r = _run(repo, "update", "chore(release): promote version") assert r.returncode == 1 + + +# --- local version policy: same-base release candidate (metadata only) -------- + +SAME_BASE = "Prepares the candidate for the existing base.\n\nRelease-Channel: rc-same-base\n" +SAME_BASE_TITLE = "chore(release): prepare same-base release candidate" + + +def _make_candidate_repo(tmp_path: Path, version: str = "0.9.3") -> Path: + """A stable-baseline repo whose branch head still equals the base commit.""" + repo = tmp_path / "repo" + (repo / "scripts").mkdir(parents=True) + (repo / "src" / "pkg").mkdir(parents=True) + (repo / "docs").mkdir() + (repo / "tests").mkdir() + shutil.copy(ROOT / "scripts" / "mw-version.py", repo / "scripts" / "mw-version.py") + (repo / ".version-policy.json").write_text( + json.dumps( + { + "schema_version": 1, + "units": [ + { + "name": "repo", + "paths": ["**"], + "files": [ + {"path": "pyproject.toml", "kind": "toml", "format": "pep440"}, + { + "path": "uv.lock", + "kind": "uv-lock", + "format": "pep440", + "name": "x", + }, + { + "path": "src/pkg/__init__.py", + "kind": "python", + "format": "pep440", + }, + ], + } + ], + "release_paths": ["src/pkg/**"], + } + ) + ) + (repo / "pyproject.toml").write_text(f'[project]\nname = "x"\nversion = "{version}"\n') + (repo / "uv.lock").write_text( + f'version = 1\n\n[[package]]\nname = "x"\nversion = "{version}"\nsource = {{ editable = "." }}\n\n' + '[[package]]\nname = "dep"\nversion = "1.0.0"\n' + ) + (repo / "src" / "pkg" / "__init__.py").write_text(f'__version__ = "{version}"\nVALUE = 1\n') + (repo / "src" / "pkg" / "core.py").write_text("VALUE = 1\n") + subprocess.run(["git", "init", "-q", "-b", "main", str(repo)], check=True) + for k, v in (("user.email", "t@example.invalid"), ("user.name", "t")): + _git(repo, "config", k, v) + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", "base") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + return repo + + +def _mirror_versions(repo: Path) -> set[str]: + values = set() + values.add(re.search(r'^version = "([^"]+)"', (repo / "pyproject.toml").read_text(), re.M).group(1)) + values.add(re.search(r'name = "x"\nversion = "([^"]+)"', (repo / "uv.lock").read_text()).group(1)) + values.add(re.search(r'__version__ = "([^"]+)"', (repo / "src/pkg/__init__.py").read_text()).group(1)) + return values + + +def test_same_base_marker_keeps_base_and_adds_rc_suffix(tmp_path): + repo = _make_candidate_repo(tmp_path) + r = _run(repo, "update", SAME_BASE_TITLE, SAME_BASE) + assert r.returncode == 0, r.stderr + assert "0.9.3 -> 0.9.3-rc.1" in r.stdout + # Every synchronized mirror carries the one PEP 440 value; the dependency is untouched. + assert _mirror_versions(repo) == {"0.9.3rc1"} + assert 'name = "dep"\nversion = "1.0.0"' in (repo / "uv.lock").read_text() + assert _run(repo, "check", SAME_BASE_TITLE, SAME_BASE).returncode == 0 + # Re-running does not accumulate and never reaches 0.9.4. + _run(repo, "update", SAME_BASE_TITLE, SAME_BASE) + assert _mirror_versions(repo) == {"0.9.3rc1"} + + +def test_same_base_marker_changes_only_version_values(tmp_path): + repo = _make_candidate_repo(tmp_path) + assert _run(repo, "update", SAME_BASE_TITLE, SAME_BASE).returncode == 0 + changed = set(_git(repo, "diff", "--name-only").splitlines()) + assert changed == {"pyproject.toml", "uv.lock", "src/pkg/__init__.py"} + # Every non-version byte equals the base: stripping the version leaves no diff. + diff = _git(repo, "diff", "-U0").splitlines() + removed = [ln for ln in diff if ln.startswith("-") and not ln.startswith("---")] + added = [ln for ln in diff if ln.startswith("+") and not ln.startswith("+++")] + assert len(removed) == len(added) == 3 + assert all("0.9.3" in ln for ln in removed) and all("0.9.3rc1" in ln for ln in added) + + +@pytest.mark.parametrize( + "path,content", + [ + ("src/pkg/core.py", "VALUE = 2\n"), # product source + ("src/pkg/new.py", "VALUE = 3\n"), # new shipped file + ("change.txt", "x\n"), # unlisted root file counts as product + ("src/pkg/__init__.py", '__version__ = "0.9.3"\nVALUE = 2\n'), # mirror edited beyond its version + ], +) +def test_same_base_rejects_any_product_change(tmp_path, path, content): + repo = _make_candidate_repo(tmp_path) + (repo / path).write_text(content) + for command in ("update", "check"): + r = _run(repo, command, SAME_BASE_TITLE, SAME_BASE) + assert r.returncode == 1, (command, r.stdout) + assert "version values only" in r.stderr and path in r.stderr + assert "rc1" not in (repo / "pyproject.toml").read_text() # update wrote nothing + + +def test_same_base_rejects_product_change_committed_on_the_branch(tmp_path): + repo = _make_candidate_repo(tmp_path) + (repo / "src" / "pkg" / "core.py").write_text("VALUE = 2\n") + _git(repo, "commit", "-qam", "fix: change behavior") + r = _run(repo, "update", SAME_BASE_TITLE, SAME_BASE) + assert r.returncode == 1 and "version values only" in r.stderr + + +def test_same_base_allows_non_release_paths(tmp_path): + repo = _make_candidate_repo(tmp_path) + (repo / "docs" / "note.md").write_text("candidate evidence\n") + (repo / "tests" / "test_x.py").write_text("def test_x():\n pass\n") + (repo / "scripts" / "check-something.py").write_text("print('ok')\n") + r = _run(repo, "update", SAME_BASE_TITLE, SAME_BASE) + assert r.returncode == 0, r.stderr + assert _mirror_versions(repo) == {"0.9.3rc1"} + assert _run(repo, "check", SAME_BASE_TITLE, SAME_BASE).returncode == 0 + + +def test_same_base_check_rejects_unprepared_and_wrong_versions(tmp_path): + repo = _make_candidate_repo(tmp_path) + r = _run(repo, "check", SAME_BASE_TITLE, SAME_BASE) # still stable 0.9.3 + assert r.returncode == 1 and "expected 0.9.3rc1" in r.stderr + for wrong in ("0.9.4rc1", "0.9.3rc2", "0.9.4"): + _git(repo, "checkout", "--", ".") + (repo / "pyproject.toml").write_text(f'[project]\nname = "x"\nversion = "{wrong}"\n') + r = _run(repo, "check", SAME_BASE_TITLE, SAME_BASE) + assert r.returncode == 1 and "expected 0.9.3rc1" in r.stderr, wrong + + +def test_version_only_rc_edit_without_marker_still_rejected(tmp_path): + repo = _make_candidate_repo(tmp_path) + (repo / "pyproject.toml").write_text('[project]\nname = "x"\nversion = "0.9.3rc1"\n') + r = _run(repo, "check", "chore: relabel", "") + assert r.returncode == 1 and "expected 0.9.3" in r.stderr + + +def test_same_base_rejected_on_prerelease_baseline(tmp_path): + repo = _make_candidate_repo(tmp_path, "0.9.3rc1") + r = _run(repo, "update", SAME_BASE_TITLE, SAME_BASE) + assert r.returncode == 1 and "stable baseline" in r.stderr + + +def test_same_base_and_start_rc_markers_are_mutually_exclusive(tmp_path): + repo = _make_candidate_repo(tmp_path) + r = _run(repo, "update", SAME_BASE_TITLE, SAME_BASE + "Release-Channel: rc\n") + assert r.returncode == 1 and "mutually exclusive" in r.stderr + + +def test_same_base_marker_must_be_exact_line(tmp_path): + repo = _make_candidate_repo(tmp_path) + for body in ( + "Release-Channel: rc-same-base2\n", + "release-channel: rc-same-base\n", + "see Release-Channel: rc-same-base here\n", + "Release-Channel: rc-same\n", + ): + _run(repo, "update", "chore: x", body) + assert _mirror_versions(repo) == {"0.9.3"}, body + _git(repo, "checkout", "--", ".") + + +def test_same_base_not_honored_for_promotion_title(tmp_path): + repo = _make_candidate_repo(tmp_path, "0.9.3rc1") + r = _run(repo, "update", "chore(release): promote version", SAME_BASE) + assert r.returncode == 0, r.stderr + assert _mirror_versions(repo) == {"0.9.3"} + + +def test_candidate_then_promotion_and_next_change_keep_normal_semantics(tmp_path): + repo = _make_candidate_repo(tmp_path) + assert _run(repo, "update", SAME_BASE_TITLE, SAME_BASE).returncode == 0 + _git(repo, "commit", "-qam", SAME_BASE_TITLE) + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + # Promotion removes only the suffix: 0.9.3rc1 -> 0.9.3, no base increment. + assert _run(repo, "update", "chore(release): promote version").returncode == 0 + assert _mirror_versions(repo) == {"0.9.3"} + _git(repo, "checkout", "--", ".") + # A later product change advances the prerelease base and resets the counter. + (repo / "src" / "pkg" / "core.py").write_text("VALUE = 2\n") + assert _run(repo, "update", "fix: later change").returncode == 0 + assert _mirror_versions(repo) == {"0.9.4rc1"} + + +# --- candidate wheel/sdist identity against the base --------------------------- + +identity = _load("rc_identity", "check-rc-artifact-identity.py") + + +def _write_artifacts( + directory: Path, version: str, *, init: str | None = None, extra: dict[str, str] | None = None, + sdist_extra: dict[str, str] | None = None, +) -> None: + import io + import tarfile + import zipfile + + directory.mkdir(parents=True, exist_ok=True) + stem = f"fava_trails-{version}" + init = init if init is not None else f'__version__ = "{version}"\n' + files = { + "fava_trails/__init__.py": init, + "fava_trails/core.py": "VALUE = 1\n", + f"{stem}.dist-info/METADATA": f"Metadata-Version: 2.4\nName: fava-trails\nVersion: {version}\n", + f"{stem}.dist-info/RECORD": f"fava_trails/__init__.py,sha256={version},1\n", + **(extra or {}), + } + with zipfile.ZipFile(directory / f"{stem}-py3-none-any.whl", "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + sdist_files = { + f"{stem}/PKG-INFO": f"Metadata-Version: 2.4\nName: fava-trails\nVersion: {version}\n", + f"{stem}/pyproject.toml": f'[project]\nname = "fava-trails"\nversion = "{version}"\n', + f"{stem}/src/fava_trails/core.py": "VALUE = 1\n", + **(sdist_extra or {}), + } + with tarfile.open(directory / f"{stem}.tar.gz", "w:gz") as tf: + for name, content in sdist_files.items(): + data = content.encode() + info = tarfile.TarInfo(name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + +def test_identity_accepts_a_version_only_candidate(tmp_path): + _write_artifacts(tmp_path / "base", "0.9.3") + _write_artifacts(tmp_path / "cand", "0.9.3rc1") + assert identity.compare_artifacts(tmp_path / "base", tmp_path / "cand", "0.9.3", "0.9.3rc1") == [] + + +def test_identity_rejects_a_changed_product_byte_in_the_wheel(tmp_path): + _write_artifacts(tmp_path / "base", "0.9.3") + _write_artifacts(tmp_path / "cand", "0.9.3rc1", extra={"fava_trails/core.py": "VALUE = 2\n"}) + problems = identity.compare_artifacts(tmp_path / "base", tmp_path / "cand", "0.9.3", "0.9.3rc1") + assert problems == ["wheel: non-version bytes differ: fava_trails/core.py"] + + +def test_identity_rejects_a_changed_line_next_to_the_version(tmp_path): + _write_artifacts(tmp_path / "base", "0.9.3") + _write_artifacts(tmp_path / "cand", "0.9.3rc1", init='__version__ = "0.9.3rc1"\nVALUE = 2\n') + problems = identity.compare_artifacts(tmp_path / "base", tmp_path / "cand", "0.9.3", "0.9.3rc1") + assert problems == ["wheel: non-version bytes differ: fava_trails/__init__.py"] + + +def test_identity_rejects_added_or_removed_files_and_sdist_changes(tmp_path): + _write_artifacts(tmp_path / "base", "0.9.3", sdist_extra={"fava_trails-0.9.3/CHANGELOG.md": "a\n"}) + _write_artifacts( + tmp_path / "cand", + "0.9.3rc1", + extra={"fava_trails/new.py": "x = 1\n"}, + sdist_extra={"fava_trails-0.9.3rc1/CHANGELOG.md": "b\n"}, + ) + problems = identity.compare_artifacts(tmp_path / "base", tmp_path / "cand", "0.9.3", "0.9.3rc1") + assert "wheel: unexpected in candidate: fava_trails/new.py" in problems + assert "sdist: non-version bytes differ: fava_trails-0.9.3/CHANGELOG.md" in problems + + +def test_identity_rejects_a_candidate_without_version_change(tmp_path): + _write_artifacts(tmp_path / "base", "0.9.3") + _write_artifacts(tmp_path / "cand", "0.9.3") + assert identity.compare_artifacts(tmp_path / "base", tmp_path / "cand", "0.9.3", "0.9.3") == [ + "candidate version equals base version 0.9.3" + ] + + +def test_identity_rejects_wrong_artifact_name(tmp_path): + _write_artifacts(tmp_path / "base", "0.9.3") + _write_artifacts(tmp_path / "cand", "0.9.3rc2") + problems = identity.compare_artifacts(tmp_path / "base", tmp_path / "cand", "0.9.3", "0.9.3rc1") + assert any("does not carry version 0.9.3rc1" in p for p in problems) diff --git a/uv.lock b/uv.lock index ccb6fd9..6bbfe21 100644 --- a/uv.lock +++ b/uv.lock @@ -495,7 +495,7 @@ wheels = [ [[package]] name = "fava-trails" -version = "0.9.3" +version = "0.9.3rc1" source = { editable = "." } dependencies = [ { name = "any-llm-sdk" },