diff --git a/src/aio_fleet/cli.py b/src/aio_fleet/cli.py index 3bdbf92..4a4aebc 100644 --- a/src/aio_fleet/cli.py +++ b/src/aio_fleet/cli.py @@ -52,7 +52,10 @@ from aio_fleet.manifest import FleetManifest, ManifestError, RepoConfig, load_manifest from aio_fleet.poll import poll_targets from aio_fleet.registry import compute_registry_tags, verify_registry_tags -from aio_fleet.release import find_release_target_commit, latest_changelog_version +from aio_fleet.release import ( + find_release_publish_target_commit, + latest_changelog_version, +) from aio_fleet.release_plan import release_plan_for_manifest, release_plan_for_repo from aio_fleet.report import ( fleet_report_json_schema, @@ -1686,7 +1689,7 @@ def cmd_release_publish(args: argparse.Namespace) -> int: latest_version = latest_changelog_version( repo.path / "CHANGELOG.md", semver=repo.publish_profile == "template" ) - release_target = find_release_target_commit(repo.path, latest_version) + release_target = find_release_publish_target_commit(repo.path, latest_version) notes = _run( [ sys.executable, diff --git a/src/aio_fleet/release.py b/src/aio_fleet/release.py index 5b71938..81fc067 100644 --- a/src/aio_fleet/release.py +++ b/src/aio_fleet/release.py @@ -13,6 +13,9 @@ AIO_CHANGELOG_HEADING = re.compile( r"^##\s+(?:\[(?P[^\]]+)\]\([^)]+\)|(?P[^\s]+))" ) +RELEASE_FORMAT_SUBJECT = re.compile( + r"^chore\(release\): format .+ changelog(?: \(#\d+\))?$" +) def main(argv: list[str] | None = None) -> int: @@ -339,6 +342,23 @@ def find_release_target_commit(repo_path: Path, version: str) -> str: return release_commit +def find_release_publish_target_commit(repo_path: Path, version: str) -> str: + release_target = find_release_target_commit(repo_path, version) + head = git(repo_path, "rev-parse", "HEAD").strip() + if release_target == head: + return release_target + if not git_is_ancestor(repo_path, release_target, head): + return release_target + subjects = git(repo_path, "log", "--format=%s", f"{release_target}..{head}") + if subjects.strip() and all( + RELEASE_FORMAT_SUBJECT.match(subject.strip()) + for subject in subjects.splitlines() + if subject.strip() + ): + return head + return release_target + + def git_tags(repo_path: Path) -> list[str]: output = git(repo_path, "tag", "--list") return [line.strip() for line in output.splitlines() if line.strip()] diff --git a/tests/test_release.py b/tests/test_release.py index 3ef9032..cd2dc45 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -4,6 +4,7 @@ from pathlib import Path from aio_fleet.release import ( + find_release_publish_target_commit, latest_changelog_version, main, next_aio_release_version, @@ -16,6 +17,12 @@ def _git(repo: Path, *args: str) -> None: subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) # nosec +def _git_output(repo: Path, *args: str) -> str: + return subprocess.check_output( # nosec B603 B607 + ["git", *args], cwd=repo, text=True + ).strip() + + def _commit(repo: Path, message: str) -> None: _git(repo, "add", ".") _git(repo, "commit", "-m", message) @@ -72,6 +79,48 @@ def test_latest_changelog_version_supports_linked_headings(tmp_path: Path) -> No assert latest_changelog_version(changelog) == "v1.2.3-aio.1" # nosec B101 +def test_release_publish_target_allows_changelog_format_followup( + tmp_path: Path, +) -> None: + _init_repo(tmp_path) + (tmp_path / "CHANGELOG.md").write_text("## Unreleased\n\n- initial\n") + _commit(tmp_path, "feat(test): initial") + (tmp_path / "CHANGELOG.md").write_text( + "## v1.0.0-aio.1 - 2026-05-10\n\n- initial\n" + ) + _commit(tmp_path, "chore(release): v1.0.0-aio.1") + release_commit = _git_output(tmp_path, "rev-parse", "HEAD") + (tmp_path / "CHANGELOG.md").write_text( + "## v1.0.0-aio.1 - 2026-05-10\n\n- initial\n\n" + ) + _commit(tmp_path, "chore(release): format test changelog") + publish_commit = _git_output(tmp_path, "rev-parse", "HEAD") + + assert release_commit != publish_commit # nosec B101 + assert ( # nosec B101 + find_release_publish_target_commit(tmp_path, "v1.0.0-aio.1") == publish_commit + ) + + +def test_release_publish_target_rejects_arbitrary_post_release_commit( + tmp_path: Path, +) -> None: + _init_repo(tmp_path) + (tmp_path / "CHANGELOG.md").write_text("## Unreleased\n\n- initial\n") + _commit(tmp_path, "feat(test): initial") + (tmp_path / "CHANGELOG.md").write_text( + "## v1.0.0-aio.1 - 2026-05-10\n\n- initial\n" + ) + _commit(tmp_path, "chore(release): v1.0.0-aio.1") + release_commit = _git_output(tmp_path, "rev-parse", "HEAD") + (tmp_path / "README.md").write_text("later\n") + _commit(tmp_path, "fix(runtime): later change") + + assert ( # nosec B101 + find_release_publish_target_commit(tmp_path, "v1.0.0-aio.1") == release_commit + ) + + def test_release_cli_supports_component_suffix(tmp_path: Path, capsys) -> None: _init_repo(tmp_path) (tmp_path / "components").mkdir()