diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c3dc56..d6ebfe2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ concurrency: jobs: aio-build: - uses: JSONbored/aio-fleet/.github/workflows/aio-build.yml@4aea17371db2faf5ba759cda63b4f46aac514162 + uses: JSONbored/aio-fleet/.github/workflows/aio-build.yml@c2c7c9a58496ce5c65e8952dc57fe455f8547833 permissions: contents: read packages: write diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml index 5fc4122..5eb668d 100644 --- a/.github/workflows/check-upstream.yml +++ b/.github/workflows/check-upstream.yml @@ -14,7 +14,7 @@ concurrency: jobs: check-upstream: - uses: JSONbored/aio-fleet/.github/workflows/aio-check-upstream.yml@4aea17371db2faf5ba759cda63b4f46aac514162 + uses: JSONbored/aio-fleet/.github/workflows/aio-check-upstream.yml@c2c7c9a58496ce5c65e8952dc57fe455f8547833 permissions: contents: write pull-requests: write diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 558dc9e..0c9278d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -8,7 +8,7 @@ permissions: jobs: publish-release: - uses: JSONbored/aio-fleet/.github/workflows/aio-publish-release.yml@4aea17371db2faf5ba759cda63b4f46aac514162 + uses: JSONbored/aio-fleet/.github/workflows/aio-publish-release.yml@c2c7c9a58496ce5c65e8952dc57fe455f8547833 permissions: actions: read contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1bcd51..44c100e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ permissions: jobs: prepare-release: - uses: JSONbored/aio-fleet/.github/workflows/aio-prepare-release.yml@4aea17371db2faf5ba759cda63b4f46aac514162 + uses: JSONbored/aio-fleet/.github/workflows/aio-prepare-release.yml@c2c7c9a58496ce5c65e8952dc57fe455f8547833 permissions: contents: write pull-requests: write diff --git a/scripts/release.py b/scripts/release.py old mode 100644 new mode 100755 index 279fa36..1d28961 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,254 +1,38 @@ #!/usr/bin/env python3 from __future__ import annotations -import argparse -import pathlib -import re -import shutil -import subprocess # nosec B404 - release helpers shell out only to trusted local git -from typing import Iterable - -try: - from components import get_component -except ImportError: # pragma: no cover - used when imported as a package module - from scripts.components import get_component - -ROOT = pathlib.Path(__file__).resolve().parents[1] -DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md" -GIT_BIN = shutil.which("git") - - -SEMVER_TAG = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") - - -def git(*args: str) -> str: - if GIT_BIN is None: - raise SystemExit("git is required to run release helpers") - return subprocess.check_output( # nosec B603 - arguments are fixed git subcommands - [GIT_BIN, *args], - cwd=ROOT, - text=True, - ).strip() - - -def semver_key(tag: str) -> tuple[int, int, int] | None: - match = SEMVER_TAG.match(tag) - if not match: - return None - return tuple(int(part) for part in match.groups()) - - -def latest_semver_tag() -> str | None: - tags = [] - for tag in git("tag", "--list").splitlines(): - tag = tag.strip() - if not tag: - continue - key = semver_key(tag) - if key is not None: - tags.append((key, tag if tag.startswith("v") else f"v{tag}")) - if not tags: - return None - tags.sort(key=lambda item: item[0]) - return tags[-1][1] - - -def latest_release_tag() -> str | None: - return latest_semver_tag() - - -def commits_since(ref: str | None) -> Iterable[str]: - args = ["log", "--format=%s"] - if ref: - args.append(f"{ref}..HEAD") - output = git(*args) - return [line.strip() for line in output.splitlines() if line.strip()] - - -def has_unreleased_changes() -> bool: - latest = latest_semver_tag() - return any(commits_since(latest)) - - -def next_release_version() -> str: - latest = latest_semver_tag() - if latest is None: - return "v0.1.0" - - major, minor, patch = semver_key(latest) # type: ignore[arg-type] - commit_messages = list(commits_since(latest)) - - has_breaking = any( - "BREAKING CHANGE" in message or re.match(r"^[a-z]+(\(.+\))?!:", message) - for message in commit_messages - ) - has_feature = any( - re.match(r"^feat(\(.+\))?:", message) for message in commit_messages - ) - - if has_breaking: - major += 1 - minor = 0 - patch = 0 - elif has_feature: - minor += 1 - patch = 0 - else: - patch += 1 - - return f"v{major}.{minor}.{patch}" - - -def latest_changelog_version(changelog: pathlib.Path) -> str: - pattern = re.compile(r"^##\s+([^\s]+)") - for line in changelog.read_text().splitlines(): - match = pattern.match(line.strip()) - if match and match.group(1) != "Unreleased": - return match.group(1) - raise SystemExit(f"Unable to find a released version heading in {changelog}") - - -def extract_release_notes(version: str, changelog: pathlib.Path) -> str: - heading = re.compile(rf"^##\s+{re.escape(version)}(?:\s+-\s+.+)?$") - next_heading = re.compile(r"^##\s+") - - lines = changelog.read_text().splitlines() - start = None - for index, line in enumerate(lines): - if heading.match(line.strip()): - start = index + 1 - break - - if start is None: - raise SystemExit(f"Unable to find release section for {version} in {changelog}") - - end = len(lines) - for index in range(start, len(lines)): - if next_heading.match(lines[index].strip()): - end = index - break - - notes = "\n".join(lines[start:end]).strip() - if not notes: - raise SystemExit(f"Release section for {version} in {changelog} is empty") - return notes - - -def find_release_commit(version: str) -> str: - exact = f"chore(release): {version}" - with_suffix = re.compile(rf"^{re.escape(exact)} \(#\d+\)$") - - output = git("log", "--format=%H\t%s", "HEAD") - for line in output.splitlines(): - if not line.strip(): - continue - sha, subject = line.split("\t", 1) - if subject == exact or with_suffix.match(subject): - return sha - - raise SystemExit( - f"Unable to find a merged release commit for {version} on main. " - f"Expected '{exact}' or '{exact} (#123)'." - ) - - -def git_completed(*args: str) -> subprocess.CompletedProcess[str]: - if GIT_BIN is None: - raise SystemExit("git is required to run release helpers") - return subprocess.run( - [GIT_BIN, *args], cwd=ROOT, text=True, capture_output=True, check=False - ) # nosec - - -def git_is_ancestor(ancestor: str, descendant: str) -> bool: - return ( - git_completed("merge-base", "--is-ancestor", ancestor, descendant).returncode - == 0 - ) - - -def find_release_target_commit(version: str) -> str: - release_commit = find_release_commit(version) - head = git("rev-parse", "HEAD").strip() - - if release_commit == head: - return release_commit - - if not git_is_ancestor(release_commit, head): +import sys +from pathlib import Path + + +def _add_local_aio_fleet() -> None: + repo_root = Path(__file__).resolve().parents[1] + for candidate in ( + repo_root / ".aio-fleet" / "src", + repo_root.parent / "aio-fleet" / "src", + ): + if candidate.exists(): + sys.path.insert(0, str(candidate)) + return + + +def main() -> int: + _add_local_aio_fleet() + try: + from aio_fleet.release import main as release_main + except ModuleNotFoundError as exc: raise SystemExit( - f"Release commit {release_commit} for {version} is not reachable from HEAD." + "aio_fleet.release is required. Run from the standard workspace with " + "../aio-fleet present, or let the reusable aio-fleet workflows check " + "out .aio-fleet before invoking this shim." + ) from exc + + return int( + release_main( + ["--repo-path", str(Path(__file__).resolve().parents[1]), *sys.argv[1:]] ) - - first_parent_commits = git( - "rev-list", "--first-parent", "--reverse", "HEAD" - ).splitlines() - for candidate in first_parent_commits: - if git_is_ancestor(release_commit, candidate): - return candidate - - return release_commit - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Release helpers for semver-based repos." - ) - parser.add_argument( - "--component", - help="Optional component name from components.toml. Semver template releases are repo-wide, so this only validates the component exists.", ) - subparsers = parser.add_subparsers(dest="command", required=True) - - subparsers.add_parser("next-version") - subparsers.add_parser("has-unreleased-changes") - subparsers.add_parser("latest-release-tag") - - latest_parser = subparsers.add_parser("latest-changelog-version") - latest_parser.add_argument( - "--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG - ) - - notes_parser = subparsers.add_parser("extract-release-notes") - notes_parser.add_argument("version") - notes_parser.add_argument( - "--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG - ) - - commit_parser = subparsers.add_parser("find-release-commit") - commit_parser.add_argument("version") - target_parser = subparsers.add_parser("find-release-target-commit") - target_parser.add_argument("version") - - args = parser.parse_args() - if args.component: - get_component(args.component) - - if args.command == "next-version": - print(next_release_version()) - return - if args.command == "has-unreleased-changes": - print("true" if has_unreleased_changes() else "false") - return - if args.command == "latest-release-tag": - latest_tag = latest_release_tag() - if latest_tag: - print(latest_tag) - return - if args.command == "latest-changelog-version": - print(latest_changelog_version(args.changelog)) - return - if args.command == "extract-release-notes": - print(extract_release_notes(args.version, args.changelog)) - return - if args.command == "find-release-commit": - print(find_release_commit(args.version)) - return - if args.command == "find-release-target-commit": - print(find_release_target_commit(args.version)) - return - - raise SystemExit(f"Unknown command: {args.command}") if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/tests/unit/test_release_helpers.py b/tests/unit/test_release_helpers.py deleted file mode 100644 index b154d69..0000000 --- a/tests/unit/test_release_helpers.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from subprocess import ( # nosec B404 - tests construct return objects only - CompletedProcess, -) - -import pytest - -from scripts import release - - -def test_find_release_target_commit_returns_squash_release_commit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fake_git(*args: str) -> str: - if args == ("log", "--format=%H\t%s", "HEAD"): - return "release-sha\tchore(release): v1.2.3" - if args == ("rev-parse", "HEAD"): - return "release-sha" - raise AssertionError(f"unexpected git args: {args}") - - monkeypatch.setattr(release, "git", fake_git) - - assert release.find_release_target_commit("v1.2.3") == "release-sha" # nosec B101 - - -def test_find_release_target_commit_returns_merge_commit_after_intervening_main_commit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fake_git(*args: str) -> str: - if args == ("log", "--format=%H\t%s", "HEAD"): - return "\n".join( - [ - "later-sha\tfix(release): later workflow fix", - "merge-sha\tMerge pull request #15 from JSONbored/release/v1.2.3", - "main-sha\tfix(ci): intervening main change", - "release-sha\tchore(release): v1.2.3", - ] - ) - if args == ("rev-parse", "HEAD"): - return "later-sha" - if args == ("rev-list", "--first-parent", "--reverse", "HEAD"): - return "main-sha\nmerge-sha\nlater-sha" - raise AssertionError(f"unexpected git args: {args}") - - def fake_git_completed(*args: str) -> CompletedProcess[str]: - ancestor_pairs = { - ("release-sha", "later-sha"), - ("release-sha", "merge-sha"), - } - if args[:2] == ("merge-base", "--is-ancestor"): - return CompletedProcess( - args=args, - returncode=0 if (args[2], args[3]) in ancestor_pairs else 1, - ) - raise AssertionError(f"unexpected git_completed args: {args}") - - monkeypatch.setattr(release, "git", fake_git) - monkeypatch.setattr(release, "git_completed", fake_git_completed) - - assert release.find_release_target_commit("v1.2.3") == "merge-sha" # nosec B101 diff --git a/tests/unit/test_release_shim.py b/tests/unit/test_release_shim.py new file mode 100644 index 0000000..201bb26 --- /dev/null +++ b/tests/unit/test_release_shim.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import subprocess # nosec B404 +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_shared_release_shim_loads() -> None: + result = subprocess.run( # nosec B603 + [sys.executable, "scripts/release.py", "has-unreleased-changes"], + cwd=ROOT, + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr # nosec B101 + assert result.stdout.strip() in {"true", "false"} # nosec B101