mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user