From 23d6573f7f46c2f55db193b8bc3b4186463c4054 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 28 Jun 2026 16:11:16 +0200 Subject: [PATCH] [F058] release-readiness: non-empty bump plan on first release _canonical_bump_files derived the bump set from the previous chore(release): commit. On the first release there is no such commit, so it returned [] -> assess set version_bump_plan=[] -> the executor published a tag with no files bumped (a no-op masquerading as X.Y.Z). Fall back to the version-reference scan when no prior release commit exists: the files currently embedding the version are exactly the set a first release must bump, and the set the first release commit then records as canonical for subsequent releases. Read-only derivation; the CEO-approval gate and fail-closed executor are untouched. --- roboco/services/release_readiness.py | 21 ++- .../test_release_readiness_first_release.py | 134 ++++++++++++++++++ 2 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 tests/unit/services/test_release_readiness_first_release.py diff --git a/roboco/services/release_readiness.py b/roboco/services/release_readiness.py index f3d33d77..a98405f6 100644 --- a/roboco/services/release_readiness.py +++ b/roboco/services/release_readiness.py @@ -412,14 +412,23 @@ def _tracked_files_with_version(root: Path, version: str) -> list[str]: return sorted(line.strip() for line in raw.splitlines() if line.strip()) -def _canonical_bump_files(root: Path) -> list[str]: +def _canonical_bump_files(root: Path, version: str) -> list[str]: + # Subsequent releases derive the canonical bump set from the previous + # ``chore(release):`` commit's touched files — the historical record of + # what a release bumps. sha = _run_git( root, ["log", "--grep", "^chore(release):", "-n1", "--format=%H"] ).strip() - if not sha: - return [] - raw = _run_git(root, ["show", "--name-only", "--format=", sha]) - return sorted(line.strip() for line in raw.splitlines() if line.strip()) + if sha: + raw = _run_git(root, ["show", "--name-only", "--format=", sha]) + return sorted(line.strip() for line in raw.splitlines() if line.strip()) + # F058: the FIRST release has no prior ``chore(release):`` commit, so the + # historical derivation returns ``[]`` and the executor would publish a tag + # with no files bumped (a no-op release masquerading as X.Y.Z). Fall back to + # the version-reference scan — the files currently embedding the version are + # exactly the set a first release must bump, and the set the first release + # commit then records as canonical for every subsequent release. Read-only. + return _tracked_files_with_version(root, version) def _new_migrations(root: Path, tag: str | None) -> list[str]: @@ -498,7 +507,7 @@ def gather_snapshot( last_tag=tag, commits=_commits_since(root, tag), tracked_files_with_version=_tracked_files_with_version(root, version), - canonical_bump_files=_canonical_bump_files(root), + canonical_bump_files=_canonical_bump_files(root, version), changelog_text=_read_changelog(root), new_migrations=_new_migrations(root, tag), migration_head_count=_migration_head_count(root), diff --git a/tests/unit/services/test_release_readiness_first_release.py b/tests/unit/services/test_release_readiness_first_release.py new file mode 100644 index 00000000..5cb0ba0a --- /dev/null +++ b/tests/unit/services/test_release_readiness_first_release.py @@ -0,0 +1,134 @@ +"""F058: the FIRST release (no prior ``chore(release):`` commit) must still +produce a non-empty version-bump plan. + +``_canonical_bump_files`` derived the bump-target set from the previous +``chore(release):`` commit's touched files. On the first release ever there is +no such commit, so it returned ``[]`` → ``assess`` set +``version_bump_plan=[]`` → ``ReleaseExecutor.apply_version_bumps`` bumped NO +files and published a tag masquerading as X.Y.Z with nothing actually changed. + +The fix: when no prior release commit exists, fall back to the version- +reference scan — the files currently embedding the version string are exactly +the set a first release must bump (and the set a subsequent release's +``chore(release):`` commit would record as canonical). This is read-only +derivation only; the CEO-approval gate and fail-closed executor are untouched. +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +import pytest +from roboco.services.release_readiness import ( + _canonical_bump_files, + assess, + gather_snapshot, +) + +if TYPE_CHECKING: + from pathlib import Path + +_TODAY = "2026-06-28" + + +def _git(root: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(root), *args], + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def _first_release_repo(tmp_path: Path) -> Path: + """A git repo at its first release: pyproject embeds 0.1.0, one feat commit, + NO prior ``chore(release):`` commit.""" + root = tmp_path / "first-release-repo" + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Test") + _git(root, "config", "user.email", "test@example.com") + (root / "pyproject.toml").write_text('version = "0.1.0"\n', encoding="utf-8") + (root / "README.md").write_text("hello\n", encoding="utf-8") + _git(root, "add", "-A") + # A non-release commit — there is intentionally NO chore(release): commit. + _git(root, "commit", "-m", "feat: initial import") + return root + + +def test_canonical_bump_files_falls_back_on_first_release(tmp_path: Path) -> None: + """No prior ``chore(release):`` commit ⇒ the canonical set is the version- + reference scan, NOT empty (the F058 regression: it returned ``[]``).""" + root = _first_release_repo(tmp_path) + files = _canonical_bump_files(root, "0.1.0") + assert files # non-empty + assert "pyproject.toml" in files + + +def test_canonical_bump_files_uses_prior_release_commit_when_present( + tmp_path: Path, +) -> None: + """A subsequent release keeps deriving the canonical set from the previous + ``chore(release):`` commit — the fallback must NOT override a real history.""" + root = _first_release_repo(tmp_path) + # Cut a real release commit touching pyproject.toml + a marker file. + (root / "RELEASE_MARKER.txt").write_text("released\n", encoding="utf-8") + (root / "pyproject.toml").write_text('version = "0.2.0"\n', encoding="utf-8") + _git(root, "add", "-A") + _git(root, "commit", "-m", "chore(release): 0.2.0") + # A further feat commit so there's something to release next. + (root / "feature.txt").write_text("x\n", encoding="utf-8") + _git(root, "add", "-A") + _git(root, "commit", "-m", "feat: add a thing") + + files = _canonical_bump_files(root, "0.2.0") + # The historical release commit wins — the marker it touched is canonical. + assert "RELEASE_MARKER.txt" in files + # And the fallback set (the version scan) must NOT have leaked the feature + # file in: it isn't version-embedding and wasn't in the release commit. + assert "feature.txt" not in files + + +def test_gather_snapshot_first_release_has_nonempty_bump_plan( + tmp_path: Path, +) -> None: + """End-to-end: the first-release snapshot's bump plan is non-empty and + carries the version-embedding file (was ``[]`` before the fix).""" + root = _first_release_repo(tmp_path) + snap = gather_snapshot(root, master_ci_conclusion="success") + assert snap.canonical_bump_files # non-empty + assert "pyproject.toml" in snap.canonical_bump_files + + +def test_assess_first_release_version_bump_plan_is_nonempty( + tmp_path: Path, +) -> None: + """The CEO-reviewable report for a first release actually plans to bump the + version-embedding file — the executor will not publish a no-op tag.""" + root = _first_release_repo(tmp_path) + report = assess( + gather_snapshot(root, master_ci_conclusion="success"), + today=_TODAY, + ) + assert report.version_bump_plan # non-empty + assert "pyproject.toml" in report.version_bump_plan + + +def test_first_release_emits_no_version_ref_gap_for_planned_files( + tmp_path: Path, +) -> None: + """On the first release the bump plan equals the version-reference scan, so + no file is flagged as 'holds the version but not in the plan' — the fallback + closes the gap the empty plan used to fabricate.""" + root = _first_release_repo(tmp_path) + report = assess( + gather_snapshot(root, master_ci_conclusion="success"), + today=_TODAY, + ) + assert not any(g.category == "version_ref" for g in report.gaps) + + +if __name__ == "__main__": + pytest.main([__file__, "-q"])