diff --git a/.github/scripts/change_scope.py b/.github/scripts/change_scope.py new file mode 100644 index 0000000..062ceec --- /dev/null +++ b/.github/scripts/change_scope.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Classify whether the current ref includes release-relevant changes.""" + +from __future__ import annotations + +import argparse +import json +import os +from dataclasses import dataclass +from pathlib import Path + +from ci_git import ( + fetch_main_branch_ref, + fetch_tags, + get_changed_files, + get_latest_tag, + get_merge_base, +) + + +@dataclass(frozen=True) +class ChangeScopeResult: + ref_name: str + range_label: str + pyproject_baseline_ref: str | None + changed_files: tuple[str, ...] + source_changed: bool + + +def parse_json_string_list(value: str) -> list[str]: + parsed = json.loads(value) + if not isinstance(parsed, list) or not all( + isinstance(item, str) for item in parsed + ): + raise ValueError("expected a JSON array of strings") + return parsed + + +def is_release_relevant_source_path( + path: str, + *, + ignored_paths: set[str], + ignored_prefixes: tuple[str, ...], +) -> bool: + if path in ignored_paths: + return False + return not path.startswith(ignored_prefixes) + + +def classify_changed_files( + changed_files: list[str], + *, + ignored_paths: set[str], + ignored_prefixes: tuple[str, ...], +) -> bool: + return any( + is_release_relevant_source_path( + path, + ignored_paths=ignored_paths, + ignored_prefixes=ignored_prefixes, + ) + for path in changed_files + ) + + +def determine_change_scope( + *, + ref_name: str, + changed_files: list[str], + latest_tag: str | None, + feature_branch_base_ref: str | None, + ignored_paths: set[str], + ignored_prefixes: tuple[str, ...], +) -> ChangeScopeResult: + if ref_name != "main": + if feature_branch_base_ref is None: + raise ValueError( + "feature_branch_base_ref is required for feature-branch mode" + ) + return ChangeScopeResult( + ref_name=ref_name, + range_label=f"{feature_branch_base_ref}...HEAD", + pyproject_baseline_ref=feature_branch_base_ref, + changed_files=tuple(changed_files), + source_changed=classify_changed_files( + changed_files, + ignored_paths=ignored_paths, + ignored_prefixes=ignored_prefixes, + ), + ) + + range_label = ( + f"{latest_tag}...HEAD" if latest_tag is not None else "tracked files in HEAD" + ) + return ChangeScopeResult( + ref_name=ref_name, + range_label=range_label, + pyproject_baseline_ref=latest_tag, + changed_files=tuple(changed_files), + source_changed=classify_changed_files( + changed_files, + ignored_paths=ignored_paths, + ignored_prefixes=ignored_prefixes, + ), + ) + + +def get_feature_branch_base_ref() -> str: + fetch_main_branch_ref() + return get_merge_base("HEAD", "origin/main") + + +def write_github_outputs(result: ChangeScopeResult) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + + outputs = { + "source_changed": str(result.source_changed).lower(), + "pyproject_baseline_ref": result.pyproject_baseline_ref or "", + } + + with Path(output_path).open("a", encoding="utf-8") as file_obj: + for key, value in outputs.items(): + file_obj.write(f"{key}={value}\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--ref-name", + required=True, + help="GitHub ref name for the current workflow run.", + ) + parser.add_argument( + "--ignored-paths-json", + required=True, + help="JSON array of exact paths that do not count as release-relevant source changes.", + ) + parser.add_argument( + "--ignored-prefixes-json", + required=True, + help="JSON array of path prefixes that do not count as release-relevant source changes.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + ignored_paths = set(parse_json_string_list(args.ignored_paths_json)) + ignored_prefixes = tuple(parse_json_string_list(args.ignored_prefixes_json)) + + fetch_tags() + + latest_tag = get_latest_tag() + if args.ref_name != "main": + feature_branch_base_ref = get_feature_branch_base_ref() + changed_files = get_changed_files(feature_branch_base_ref) + else: + feature_branch_base_ref = None + changed_files = get_changed_files(latest_tag) + + result = determine_change_scope( + ref_name=args.ref_name, + changed_files=changed_files, + latest_tag=latest_tag, + feature_branch_base_ref=feature_branch_base_ref, + ignored_paths=ignored_paths, + ignored_prefixes=ignored_prefixes, + ) + write_github_outputs(result) + + print(f"Change scope ({args.ref_name})") + print(f" range: {result.range_label}") + print( + f" pyproject baseline: {result.pyproject_baseline_ref or '(latest tag baseline unavailable)'}" + ) + print(f" ignored paths: {sorted(ignored_paths)}") + print(f" ignored prefixes: {list(ignored_prefixes)}") + print(f" release-relevant source changed: {str(result.source_changed).lower()}") + if result.changed_files: + print(" compared files:") + for path in result.changed_files: + print(f" - {path}") + else: + print(" compared files: (none)") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/ci_git.py b/.github/scripts/ci_git.py new file mode 100644 index 0000000..f63600d --- /dev/null +++ b/.github/scripts/ci_git.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Shared git and tag helpers for CI workflow scripts.""" + +from __future__ import annotations + +import re +import subprocess +from collections.abc import Iterable + +SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + + +def run_git(*args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + check=check, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def parse_tag(tag: str) -> tuple[int, int, int]: + match = SEMVER_TAG_RE.fullmatch(tag) + if not match: + raise ValueError(f"Unsupported tag format: {tag}") + return tuple(int(part) for part in match.groups()) + + +def filter_semver_tags(tags: Iterable[str]) -> list[str]: + valid_tags = [tag for tag in tags if SEMVER_TAG_RE.fullmatch(tag)] + return sorted(valid_tags, key=parse_tag) + + +def fetch_tags() -> None: + subprocess.run( + ["git", "fetch", "--force", "--tags", "origin"], + check=True, + capture_output=True, + text=True, + ) + + +def fetch_main_branch_ref() -> None: + subprocess.run( + ["git", "fetch", "--no-tags", "origin", "main:refs/remotes/origin/main"], + check=True, + capture_output=True, + text=True, + ) + + +def get_latest_tag() -> str | None: + tags_output = run_git("tag", "--list", "v*") + tags = [line.strip() for line in tags_output.splitlines() if line.strip()] + valid_tags = filter_semver_tags(tags) + if not valid_tags: + return None + return valid_tags[-1] + + +def get_merge_base(left_ref: str, right_ref: str) -> str: + return run_git("merge-base", left_ref, right_ref) + + +def get_changed_files(diff_ref: str | None) -> list[str]: + if diff_ref is None: + output = run_git("ls-files") + else: + output = run_git( + "diff", + "--name-only", + "--diff-filter=ACDMRTUXB", + f"{diff_ref}...HEAD", + ) + return [line.strip() for line in output.splitlines() if line.strip()] diff --git a/.github/scripts/version_policy.py b/.github/scripts/version_policy.py new file mode 100644 index 0000000..5beded8 --- /dev/null +++ b/.github/scripts/version_policy.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Enforce release version policy for CI workflows.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ci_git import fetch_tags, get_latest_tag + +SEMVER_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +@dataclass(frozen=True) +class PolicyResult: + mode: str + current_version: str + current_tag: str + baseline_ref: str + baseline_version: str | None + source_changed: bool + requires_version_bump: bool + has_version_bump: bool + should_release: bool + relevant_pyproject_changed: bool + failure_reason: str | None + + +def parse_version(version: str) -> tuple[int, int, int]: + match = SEMVER_VERSION_RE.fullmatch(version) + if not match: + raise ValueError(f"Unsupported version format: {version}") + return tuple(int(part) for part in match.groups()) + + +def compare_versions(current_version: str, baseline_version: str) -> int: + current = parse_version(current_version) + baseline = parse_version(baseline_version) + if current > baseline: + return 1 + if current < baseline: + return -1 + return 0 + + +def is_relevant_pyproject_change( + current_pyproject: dict[str, Any], baseline_pyproject: dict[str, Any] | None +) -> bool: + if baseline_pyproject is None: + return False + + current_project = dict(current_pyproject.get("project", {})) + baseline_project = dict(baseline_pyproject.get("project", {})) + current_project.pop("version", None) + baseline_project.pop("version", None) + + current_tool = current_pyproject.get("tool", {}) + baseline_tool = baseline_pyproject.get("tool", {}) + + relevant_current_tool = { + "hatch": current_tool.get("hatch", {}), + "uv": current_tool.get("uv", {}), + } + relevant_baseline_tool = { + "hatch": baseline_tool.get("hatch", {}), + "uv": baseline_tool.get("uv", {}), + } + + return ( + current_pyproject.get("build-system", {}) + != baseline_pyproject.get("build-system", {}) + or current_project != baseline_project + or relevant_current_tool != relevant_baseline_tool + ) + + +def evaluate_policy( + *, + mode: str, + source_changed: bool, + current_pyproject: dict[str, Any], + baseline_pyproject: dict[str, Any] | None, + baseline_ref: str, + baseline_version: str | None, +) -> PolicyResult: + current_version = current_pyproject["project"]["version"] + current_tag = f"v{current_version}" + relevant_change = is_relevant_pyproject_change( + current_pyproject, baseline_pyproject + ) + bump_required = source_changed or relevant_change + comparison = ( + 1 + if baseline_version is None + else compare_versions(current_version, baseline_version) + ) + has_bump = comparison > 0 + should_release = mode == "release" and has_bump + failure_reason: str | None = None + + if mode == "release": + if baseline_version is not None and comparison < 0: + failure_reason = f"Current version {current_tag} is behind latest release {baseline_ref}." + elif baseline_version is not None and bump_required and comparison <= 0: + failure_reason = ( + "Changes require a semantic version bump, " + f"but {current_tag} is not greater than {baseline_ref}." + ) + elif baseline_version is not None and bump_required and comparison <= 0: + failure_reason = ( + "Changes require a semantic version bump compared with " + f"{baseline_ref} version v{baseline_version}, " + f"but current version is {current_tag}." + ) + + return PolicyResult( + mode=mode, + current_version=current_version, + current_tag=current_tag, + baseline_ref=baseline_ref, + baseline_version=baseline_version, + source_changed=source_changed, + requires_version_bump=bump_required, + has_version_bump=has_bump, + should_release=should_release, + relevant_pyproject_changed=relevant_change, + failure_reason=failure_reason, + ) + + +def load_pyproject(path: Path) -> dict[str, Any]: + with path.open("rb") as file_obj: + return tomllib.load(file_obj) + + +def load_pyproject_from_ref(ref: str | None, repo_root: Path) -> dict[str, Any] | None: + if ref is None: + return None + + result = subprocess.run( + ["git", "show", f"{ref}:pyproject.toml"], + check=False, + capture_output=True, + text=True, + cwd=repo_root, + ) + if result.returncode != 0: + return None + return tomllib.loads(result.stdout) + + +def write_github_outputs(result: PolicyResult) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + + outputs = { + "current_version": result.current_version, + "current_tag": result.current_tag, + "baseline_ref": result.baseline_ref, + "source_changed": str(result.source_changed).lower(), + "requires_version_bump": str(result.requires_version_bump).lower(), + "has_version_bump": str(result.has_version_bump).lower(), + "relevant_pyproject_changed": str(result.relevant_pyproject_changed).lower(), + "should_release": str(result.should_release).lower(), + } + + with Path(output_path).open("a", encoding="utf-8") as file_obj: + for key, value in outputs.items(): + file_obj.write(f"{key}={value}\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--mode", + choices=("feature-branch", "release"), + required=True, + help="Execution mode for workflow messaging.", + ) + parser.add_argument( + "--source-changed", + choices=("true", "false"), + required=True, + help="Whether the workflow determined that release-relevant non-pyproject files changed.", + ) + parser.add_argument( + "--pyproject-baseline-ref", + default="", + help="Git ref to use as the pyproject.toml comparison baseline.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repo_root = Path.cwd() + fetch_tags() + + latest_tag = get_latest_tag() + current_pyproject = load_pyproject(repo_root / "pyproject.toml") + source_changed = args.source_changed == "true" + + if args.mode == "feature-branch": + pyproject_baseline_ref = args.pyproject_baseline_ref + baseline_pyproject = load_pyproject_from_ref(pyproject_baseline_ref, repo_root) + baseline_ref = pyproject_baseline_ref or "feature branch base" + baseline_version = ( + None + if baseline_pyproject is None + else str(baseline_pyproject["project"]["version"]) + ) + else: + pyproject_baseline_ref = args.pyproject_baseline_ref or latest_tag + baseline_pyproject = load_pyproject_from_ref(pyproject_baseline_ref, repo_root) + baseline_ref = latest_tag or "initial repository state" + baseline_version = latest_tag[1:] if latest_tag is not None else None + + result = evaluate_policy( + mode=args.mode, + source_changed=source_changed, + current_pyproject=current_pyproject, + baseline_pyproject=baseline_pyproject, + baseline_ref=baseline_ref, + baseline_version=baseline_version, + ) + write_github_outputs(result) + + print(f"Version policy check ({args.mode})") + print(f" baseline: {result.baseline_ref}") + print(f" current tag: {result.current_tag}") + print(f" pyproject baseline: {pyproject_baseline_ref or '(none)'}") + print(f" source changed: {str(result.source_changed).lower()}") + print(f" requires bump: {str(result.requires_version_bump).lower()}") + print(f" should release: {str(result.should_release).lower()}") + + if result.failure_reason: + print(result.failure_reason, file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/tests/test_change_scope.py b/.github/tests/test_change_scope.py new file mode 100644 index 0000000..d122347 --- /dev/null +++ b/.github/tests/test_change_scope.py @@ -0,0 +1,163 @@ +"""Tests for the CI change scope helper.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +MODULE_PATH = ( + Path(__file__).resolve().parents[2] / ".github" / "scripts" / "change_scope.py" +) +MODULE_NAME = "dispatch_cli_ci_change_scope" +sys.path.insert(0, str(MODULE_PATH.parent)) + +IGNORED_PATHS = { + "README.md", + "CONTRIBUTING.md", + "NOTICE", + "LICENSE", + "LICENSE-3rdparty.csv", + "SKILL.md", + "uv.lock", + "pyproject.toml", +} +IGNORED_PREFIXES = (".github/", "tests/", "tasks/", "LICENSE") + +spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) +assert spec is not None +assert spec.loader is not None +change_scope = importlib.util.module_from_spec(spec) +sys.modules[MODULE_NAME] = change_scope +spec.loader.exec_module(change_scope) + + +def test_docs_and_workflow_changes_are_not_release_relevant(): + assert ( + change_scope.classify_changed_files( + [ + "README.md", + ".github/workflows/feature-branch.yml", + "tests/test_cli.py", + "tasks/todo.md", + ], + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + is False + ) + + +def test_pyproject_change_is_deferred_to_semantic_diff(): + assert ( + change_scope.classify_changed_files( + ["pyproject.toml"], + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + is False + ) + + +def test_cli_source_change_is_release_relevant(): + assert ( + change_scope.classify_changed_files( + ["dispatch_cli/main.py"], + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + is True + ) + + +def test_unknown_top_level_path_is_conservatively_relevant(): + assert ( + change_scope.classify_changed_files( + ["new_surface/config.json"], + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + is True + ) + + +def test_license_prefix_is_ignored(): + assert ( + change_scope.classify_changed_files( + ["LICENSES/internal.txt"], + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + is False + ) + + +def test_feature_branch_scope_uses_branch_base_for_pyproject(): + result = change_scope.determine_change_scope( + ref_name="feature/foo", + changed_files=[".github/workflows/release.yml"], + latest_tag="v0.5.0", + feature_branch_base_ref="abc123", + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + + assert result.range_label == "abc123...HEAD" + assert result.pyproject_baseline_ref == "abc123" + assert result.source_changed is False + + +def test_release_scope_uses_latest_tag(): + result = change_scope.determine_change_scope( + ref_name="main", + changed_files=["dispatch_cli/main.py"], + latest_tag="v0.5.0", + feature_branch_base_ref=None, + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + + assert result.range_label == "v0.5.0...HEAD" + assert result.pyproject_baseline_ref == "v0.5.0" + assert result.source_changed is True + + +def test_feature_branch_scope_requires_base_ref(): + with pytest.raises(ValueError): + change_scope.determine_change_scope( + ref_name="feature/foo", + changed_files=[], + latest_tag="v0.5.0", + feature_branch_base_ref=None, + ignored_paths=set(IGNORED_PATHS), + ignored_prefixes=IGNORED_PREFIXES, + ) + + +def test_parse_json_string_list_rejects_non_string_lists(): + with pytest.raises(ValueError): + change_scope.parse_json_string_list('["ok", 1]') + + +def test_parse_args_requires_explicit_policy_values(monkeypatch): + monkeypatch.setattr( + sys, + "argv", + [ + "change_scope.py", + "--ref-name", + "main", + "--ignored-paths-json", + '["README.md"]', + "--ignored-prefixes-json", + '[".github/"]', + ], + ) + + args = change_scope.parse_args() + + assert args.ref_name == "main" + assert args.ignored_paths_json == '["README.md"]' + assert args.ignored_prefixes_json == '[".github/"]' diff --git a/.github/tests/test_ci_git.py b/.github/tests/test_ci_git.py new file mode 100644 index 0000000..734b5e1 --- /dev/null +++ b/.github/tests/test_ci_git.py @@ -0,0 +1,113 @@ +"""Tests for shared CI git helpers.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +MODULE_PATH = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "ci_git.py" +MODULE_NAME = "dispatch_cli_ci_git" +sys.path.insert(0, str(MODULE_PATH.parent)) + +spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) +assert spec is not None +assert spec.loader is not None +ci_git = importlib.util.module_from_spec(spec) +sys.modules[MODULE_NAME] = ci_git +spec.loader.exec_module(ci_git) + + +def test_parse_tag_parses_semver(): + assert ci_git.parse_tag("v1.2.3") == (1, 2, 3) + + +def test_parse_tag_rejects_invalid_tag(): + with pytest.raises(ValueError): + ci_git.parse_tag("release-1.2.3") + + +def test_filter_semver_tags_filters_and_sorts(): + assert ci_git.filter_semver_tags(["v1.10.0", "foo", "v1.2.0"]) == [ + "v1.2.0", + "v1.10.0", + ] + + +def test_get_latest_tag_returns_none_when_no_semver_tags(monkeypatch): + monkeypatch.setattr(ci_git, "run_git", lambda *args, **kwargs: "foo\nbar") + + assert ci_git.get_latest_tag() is None + + +def test_get_latest_tag_returns_last_semver(monkeypatch): + monkeypatch.setattr(ci_git, "run_git", lambda *args, **kwargs: "v0.1.0\nv0.2.0\n") + + assert ci_git.get_latest_tag() == "v0.2.0" + + +def test_get_merge_base_delegates_to_git(monkeypatch): + monkeypatch.setattr(ci_git, "run_git", lambda *args, **kwargs: "abc123") + + assert ci_git.get_merge_base("HEAD", "origin/main") == "abc123" + + +def test_get_changed_files_for_ref(monkeypatch): + monkeypatch.setattr( + ci_git, + "run_git", + lambda *args, **kwargs: "dispatch_cli/main.py\nREADME.md\n", + ) + + assert ci_git.get_changed_files("v0.1.0") == [ + "dispatch_cli/main.py", + "README.md", + ] + + +def test_get_changed_files_without_ref_uses_ls_files(monkeypatch): + calls: list[tuple[str, ...]] = [] + + def fake_run_git(*args, **kwargs): + calls.append(args) + return "a.py\n" + + monkeypatch.setattr(ci_git, "run_git", fake_run_git) + + assert ci_git.get_changed_files(None) == ["a.py"] + assert calls == [("ls-files",)] + + +def test_fetch_tags_raises_on_failure(monkeypatch): + def fake_run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=1, + cmd=["git", "fetch", "--force", "--tags", "origin"], + ) + + monkeypatch.setattr(ci_git.subprocess, "run", fake_run) + + with pytest.raises(subprocess.CalledProcessError): + ci_git.fetch_tags() + + +def test_fetch_main_branch_ref_raises_on_failure(monkeypatch): + def fake_run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=1, + cmd=[ + "git", + "fetch", + "--no-tags", + "origin", + "main:refs/remotes/origin/main", + ], + ) + + monkeypatch.setattr(ci_git.subprocess, "run", fake_run) + + with pytest.raises(subprocess.CalledProcessError): + ci_git.fetch_main_branch_ref() diff --git a/.github/tests/test_version_policy.py b/.github/tests/test_version_policy.py new file mode 100644 index 0000000..49d4b04 --- /dev/null +++ b/.github/tests/test_version_policy.py @@ -0,0 +1,265 @@ +"""Tests for the CI version policy helper.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +MODULE_PATH = ( + Path(__file__).resolve().parents[2] / ".github" / "scripts" / "version_policy.py" +) +MODULE_NAME = "dispatch_cli_ci_version_policy" +sys.path.insert(0, str(MODULE_PATH.parent)) + +spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) +assert spec is not None +assert spec.loader is not None +version_policy = importlib.util.module_from_spec(spec) +sys.modules[MODULE_NAME] = version_policy +spec.loader.exec_module(version_policy) + + +def pyproject( + *, + version: str, + description: str = "Dispatch CLI", + dependency_sources: dict[str, dict[str, str]] | None = None, + dev_dependencies: list[str] | None = None, +) -> dict[str, object]: + return { + "build-system": { + "requires": ["hatchling"], + "build-backend": "hatchling.build", + }, + "project": { + "name": "dispatch-cli", + "version": version, + "description": description, + "dependencies": ["typer>=0.16.1", "dispatch_agents"], + "scripts": {"dispatch": "dispatch_cli.main:app"}, + }, + "tool": { + "hatch": { + "build": {"targets": {"wheel": {"packages": ["dispatch_cli"]}}}, + "metadata": {"allow-direct-references": True}, + }, + "uv": { + "sources": dependency_sources + or { + "dispatch_agents": { + "git": "https://github.com/datadog-labs/dispatch_agents_sdk", + "tag": "v0.7.3", + } + } + }, + "ruff": {"line-length": 88}, + }, + "dependency-groups": { + "dev": dev_dependencies or ["pytest>=7.0.0", "ruff>=0.8.0"] + }, + } + + +def test_release_relevant_change_requires_bump_when_version_is_unchanged(): + result = version_policy.evaluate_policy( + mode="feature-branch", + source_changed=True, + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="abc123", + baseline_version="0.5.0", + ) + + assert result.source_changed is True + assert result.requires_version_bump is True + assert result.has_version_bump is False + assert result.should_release is False + assert result.failure_reason is not None + + +def test_release_relevant_change_passes_with_higher_version(): + result = version_policy.evaluate_policy( + mode="feature-branch", + source_changed=True, + current_pyproject=pyproject(version="0.5.1"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="abc123", + baseline_version="0.5.0", + ) + + assert result.requires_version_bump is True + assert result.has_version_bump is True + assert result.should_release is False + assert result.failure_reason is None + + +def test_docs_only_change_does_not_require_bump(): + result = version_policy.evaluate_policy( + mode="feature-branch", + source_changed=False, + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="abc123", + baseline_version="0.5.0", + ) + + assert result.requires_version_bump is False + assert result.has_version_bump is False + assert result.should_release is False + assert result.failure_reason is None + + +def test_relevant_pyproject_change_requires_bump(): + result = version_policy.evaluate_policy( + mode="feature-branch", + source_changed=False, + current_pyproject=pyproject(version="0.5.0", description="Updated CLI"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="abc123", + baseline_version="0.5.0", + ) + + assert result.relevant_pyproject_changed is True + assert result.requires_version_bump is True + assert result.failure_reason is not None + + +def test_dev_tooling_pyproject_change_does_not_require_bump(): + result = version_policy.evaluate_policy( + mode="feature-branch", + source_changed=False, + current_pyproject=pyproject( + version="0.5.0", + dev_dependencies=["pytest>=7.0.0", "ruff>=0.9.0"], + ), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="abc123", + baseline_version="0.5.0", + ) + + assert result.relevant_pyproject_changed is False + assert result.requires_version_bump is False + assert result.failure_reason is None + + +def test_feature_branch_docs_only_change_can_be_behind_main_version(): + result = version_policy.evaluate_policy( + mode="feature-branch", + source_changed=False, + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.1"), + baseline_ref="abc123", + baseline_version="0.5.1", + ) + + assert result.requires_version_bump is False + assert result.failure_reason is None + + +def test_release_change_requires_bump_when_version_is_unchanged(): + result = version_policy.evaluate_policy( + mode="release", + source_changed=True, + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.4.9"), + baseline_ref="v0.5.0", + baseline_version="0.5.0", + ) + + assert result.requires_version_bump is True + assert result.has_version_bump is False + assert result.should_release is False + assert result.failure_reason is not None + + +def test_release_change_passes_with_higher_version(): + result = version_policy.evaluate_policy( + mode="release", + source_changed=True, + current_pyproject=pyproject(version="0.5.1"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="v0.5.0", + baseline_version="0.5.0", + ) + + assert result.requires_version_bump is True + assert result.has_version_bump is True + assert result.should_release is True + assert result.failure_reason is None + + +def test_release_docs_only_change_does_not_require_release(): + result = version_policy.evaluate_policy( + mode="release", + source_changed=False, + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="v0.5.0", + baseline_version="0.5.0", + ) + + assert result.requires_version_bump is False + assert result.should_release is False + assert result.failure_reason is None + + +def test_release_lower_than_latest_tag_fails(): + result = version_policy.evaluate_policy( + mode="release", + source_changed=False, + current_pyproject=pyproject(version="0.4.9"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="v0.5.0", + baseline_version="0.5.0", + ) + + assert result.failure_reason == ( + "Current version v0.4.9 is behind latest release v0.5.0." + ) + + +def test_workflow_classified_release_relevant_change_requires_bump(): + result = version_policy.evaluate_policy( + mode="feature-branch", + source_changed=True, + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="abc123", + baseline_version="0.5.0", + ) + + assert result.requires_version_bump is True + assert result.failure_reason is not None + + +def test_release_without_prior_tag_triggers_initial_release(): + result = version_policy.evaluate_policy( + mode="release", + source_changed=True, + current_pyproject=pyproject(version="0.1.0"), + baseline_pyproject=None, + baseline_ref="initial repository state", + baseline_version=None, + ) + + assert result.requires_version_bump is True + assert result.has_version_bump is True + assert result.should_release is True + assert result.failure_reason is None + + +def test_fetch_tags_raises_on_failure(monkeypatch): + def fake_run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=1, + cmd=["git", "fetch", "--force", "--tags", "origin"], + ) + + monkeypatch.setattr(version_policy, "fetch_tags", fake_run) + + with pytest.raises(subprocess.CalledProcessError): + version_policy.fetch_tags() diff --git a/.github/workflows/ci-reusable.yml b/.github/workflows/ci-reusable.yml new file mode 100644 index 0000000..a8eb701 --- /dev/null +++ b/.github/workflows/ci-reusable.yml @@ -0,0 +1,61 @@ +name: Reusable CI + +on: + workflow_call: + inputs: + upload_dist_artifact: + description: Upload the build artifact for downstream jobs. + required: false + type: boolean + default: false + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 + - name: Install dev dependencies + run: uv sync --group dev --locked + - name: Lint + run: uv run ruff check . + - name: Format check + run: uv run ruff format --check . + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 + - name: Install dev dependencies + run: uv sync --group dev --locked + - name: mypy + run: uv run mypy dispatch_cli + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 + - name: Install dev dependencies + run: uv sync --group dev --locked + - name: pytest + run: uv run pytest --cov=dispatch_cli --cov-report=term-missing tests .github/tests + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 + - name: Build wheel and sdist + run: uv build + - name: Upload dist artifacts + if: ${{ inputs.upload_dist_artifact }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: dist + path: dist/ diff --git a/.github/workflows/feature-branch.yml b/.github/workflows/feature-branch.yml new file mode 100644 index 0000000..afc0519 --- /dev/null +++ b/.github/workflows/feature-branch.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches-ignore: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + version_policy: + uses: ./.github/workflows/version-policy-reusable.yml + with: + mode: feature-branch + + ci: + name: CI + needs: version_policy + uses: ./.github/workflows/ci-reusable.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..120d23a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + version_policy: + uses: ./.github/workflows/version-policy-reusable.yml + with: + mode: release + + ci: + name: CI + needs: version_policy + uses: ./.github/workflows/ci-reusable.yml + with: + upload_dist_artifact: true + + release: + name: GitHub Release + needs: [version_policy, ci] + if: needs.version_policy.outputs.should_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download dist artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist + path: dist/ + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.version_policy.outputs.current_tag }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + else + gh release create "$TAG" dist/* \ + --title "$TAG" \ + --generate-notes \ + --target "$GITHUB_SHA" + fi diff --git a/.github/workflows/version-policy-reusable.yml b/.github/workflows/version-policy-reusable.yml new file mode 100644 index 0000000..a5f2055 --- /dev/null +++ b/.github/workflows/version-policy-reusable.yml @@ -0,0 +1,58 @@ +name: Reusable Version Policy + +on: + workflow_call: + inputs: + mode: + description: Version policy mode. + required: true + type: string + ignored_paths_json: + description: JSON array of exact paths ignored by release-scope classification. + required: false + type: string + default: '["README.md","CONTRIBUTING.md","NOTICE","LICENSE","LICENSE-3rdparty.csv","SKILL.md","uv.lock","pyproject.toml"]' + ignored_prefixes_json: + description: JSON array of path prefixes ignored by release-scope classification. + required: false + type: string + default: '[".github/","tests/","tasks/","LICENSE"]' + outputs: + current_tag: + description: Current version tag derived from pyproject version. + value: ${{ jobs.version_policy.outputs.current_tag }} + should_release: + description: Whether the current main commit should produce a release. + value: ${{ jobs.version_policy.outputs.should_release }} + +permissions: + contents: read + +jobs: + version_policy: + name: Version Policy + runs-on: ubuntu-latest + outputs: + current_tag: ${{ steps.version_policy.outputs.current_tag }} + should_release: ${{ steps.version_policy.outputs.should_release }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 + - name: Classify release-relevant changes + id: change_scope + run: > + uv run python .github/scripts/change_scope.py + --ref-name "${{ github.ref_name }}" + --ignored-paths-json '${{ inputs.ignored_paths_json }}' + --ignored-prefixes-json '${{ inputs.ignored_prefixes_json }}' + - name: Install dev dependencies + run: uv sync --group dev --locked + - name: Enforce version policy + id: version_policy + run: > + uv run python .github/scripts/version_policy.py + --mode "${{ inputs.mode }}" + --source-changed "${{ steps.change_scope.outputs.source_changed }}" + --pyproject-baseline-ref "${{ steps.change_scope.outputs.pyproject_baseline_ref }}"