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
25 changes: 25 additions & 0 deletions docs/version-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
157 changes: 157 additions & 0 deletions scripts/check-rc-artifact-identity.py
Original file line number Diff line number Diff line change
@@ -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())
31 changes: 30 additions & 1 deletion scripts/mw-version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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", []))}
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/fava_trails/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading