From 03c145250659d3c701eea798d77aad6094705846 Mon Sep 17 00:00:00 2001 From: Jack Burns Date: Mon, 16 Mar 2026 14:41:44 -0400 Subject: [PATCH 1/2] ci: add GitHub Actions release pipeline --- .github/scripts/version_policy.py | 394 ++++++++++++++++++ .github/tests/test_version_policy.py | 257 ++++++++++++ .github/workflows/ci-reusable.yml | 61 +++ .github/workflows/feature-branch.yml | 22 + .github/workflows/release.yml | 56 +++ .github/workflows/version-policy-reusable.yml | 45 ++ 6 files changed, 835 insertions(+) create mode 100644 .github/scripts/version_policy.py create mode 100644 .github/tests/test_version_policy.py create mode 100644 .github/workflows/ci-reusable.yml create mode 100644 .github/workflows/feature-branch.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/version-policy-reusable.yml diff --git a/.github/scripts/version_policy.py b/.github/scripts/version_policy.py new file mode 100644 index 0000000..59abe7c --- /dev/null +++ b/.github/scripts/version_policy.py @@ -0,0 +1,394 @@ +#!/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 collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") +SEMVER_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + +IGNORED_PATH_PREFIXES = (".github/", "tests/", "tasks/") +IGNORED_PATHS = { + "README.md", + "CONTRIBUTING.md", + "NOTICE", + "LICENSE", + "LICENSE-3rdparty.csv", + "SKILL.md", + "uv.lock", +} + + +@dataclass(frozen=True) +class PolicyResult: + mode: str + current_version: str + current_tag: str + baseline_ref: str + baseline_version: str | None + requires_version_bump: bool + has_version_bump: bool + should_release: bool + relevant_pyproject_changed: bool + unknown_paths: tuple[str, ...] + 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 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 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 extract_shipped_path_prefixes(pyproject_data: dict[str, Any]) -> tuple[str, ...]: + packages = ( + pyproject_data.get("tool", {}) + .get("hatch", {}) + .get("build", {}) + .get("targets", {}) + .get("wheel", {}) + .get("packages", []) + ) + normalized = [] + for package in packages: + package_name = str(package).strip().strip("/") + if package_name: + normalized.append(f"{package_name}/") + return tuple(normalized) + + +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 is_shipped_path(path: str, shipped_path_prefixes: tuple[str, ...]) -> bool: + return path.startswith(shipped_path_prefixes) + + +def is_ignored_path(path: str) -> bool: + if path in IGNORED_PATHS: + return True + if path.startswith("LICENSE"): + return True + return path.startswith(IGNORED_PATH_PREFIXES) + + +def requires_version_bump( + changed_files: Iterable[str], + relevant_pyproject_changed: bool, + unknown_paths: Iterable[str], + shipped_path_prefixes: tuple[str, ...], +) -> bool: + if relevant_pyproject_changed: + return True + return any( + is_shipped_path(path, shipped_path_prefixes) for path in changed_files + ) or any(unknown_paths) + + +def evaluate_policy( + *, + mode: str, + changed_files: Iterable[str], + 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}" + shipped_path_prefixes = extract_shipped_path_prefixes(current_pyproject) + relevant_change = is_relevant_pyproject_change( + current_pyproject, baseline_pyproject + ) + changed_files = list(changed_files) + unknown_paths = tuple( + path + for path in changed_files + if not is_ignored_path(path) + and not is_shipped_path(path, shipped_path_prefixes) + and path != "pyproject.toml" + ) + bump_required = requires_version_bump( + changed_files, + relevant_change, + unknown_paths, + shipped_path_prefixes, + ) + + 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}." + ) + else: + if 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, + requires_version_bump=bump_required, + has_version_bump=has_bump, + should_release=should_release, + relevant_pyproject_changed=relevant_change, + unknown_paths=unknown_paths, + failure_reason=failure_reason, + ) + + +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 fetch_default_branch(default_branch: str) -> None: + subprocess.run( + ["git", "fetch", "--force", "origin", default_branch], + check=True, + capture_output=True, + text=True, + ) + + +def fetch_tags() -> None: + subprocess.run( + ["git", "fetch", "--force", "--tags", "origin"], + 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(default_branch: str) -> str: + return run_git("merge-base", "HEAD", f"origin/{default_branch}") + + +def get_changed_files_since_ref(ref: str) -> list[str]: + output = run_git( + "diff", + "--name-only", + "--diff-filter=ACDMRTUXB", + f"{ref}..HEAD", + ) + return [line.strip() for line in output.splitlines() if line.strip()] + + +def get_changed_files_since_tag(latest_tag: str | None) -> list[str]: + if latest_tag is None: + output = run_git("ls-files") + else: + output = run_git( + "diff", + "--name-only", + "--diff-filter=ACDMRTUXB", + f"{latest_tag}...HEAD", + ) + return [line.strip() for line in output.splitlines() if line.strip()] + + +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, + "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( + "--default-branch", + default="main", + help="Default branch name used for feature-branch comparisons.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repo_root = Path.cwd() + current_pyproject = load_pyproject(repo_root / "pyproject.toml") + + if args.mode == "feature-branch": + fetch_default_branch(args.default_branch) + baseline_ref = get_merge_base(args.default_branch) + changed_files = get_changed_files_since_ref(baseline_ref) + baseline_pyproject = load_pyproject_from_ref(baseline_ref, repo_root) + baseline_version = ( + None + if baseline_pyproject is None + else str(baseline_pyproject["project"]["version"]) + ) + baseline_label = f"origin/{args.default_branch}" + else: + fetch_tags() + latest_tag = get_latest_tag() + changed_files = get_changed_files_since_tag(latest_tag) + baseline_pyproject = load_pyproject_from_ref(latest_tag, repo_root) + baseline_ref = latest_tag or "initial repository state" + baseline_version = latest_tag[1:] if latest_tag is not None else None + baseline_label = baseline_ref + + result = evaluate_policy( + mode=args.mode, + changed_files=changed_files, + current_pyproject=current_pyproject, + baseline_pyproject=baseline_pyproject, + baseline_ref=baseline_label, + 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" requires bump: {str(result.requires_version_bump).lower()}") + print(f" should release: {str(result.should_release).lower()}") + + if result.unknown_paths: + print(" unknown paths conservatively treated as release-relevant:") + for path in result.unknown_paths: + print(f" - {path}") + + 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_version_policy.py b/.github/tests/test_version_policy.py new file mode 100644 index 0000000..cfdbe3d --- /dev/null +++ b/.github/tests/test_version_policy.py @@ -0,0 +1,257 @@ +"""Tests for the CI version policy helper.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +MODULE_PATH = ( + Path(__file__).resolve().parents[2] / ".github" / "scripts" / "version_policy.py" +) +MODULE_NAME = "dispatch_cli_ci_version_policy" + +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", + packages: list[str] | None = None, + 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": packages or ["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_extract_shipped_path_prefixes_from_hatch_packages(): + assert version_policy.extract_shipped_path_prefixes( + pyproject(version="0.5.0", packages=["dispatch_cli", "other_pkg"]) + ) == ("dispatch_cli/", "other_pkg/") + + +def test_feature_branch_sdk_change_requires_bump_when_version_is_unchanged(): + result = version_policy.evaluate_policy( + mode="feature-branch", + changed_files=["dispatch_cli/main.py"], + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="origin/main", + 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_feature_branch_sdk_change_passes_with_higher_version(): + result = version_policy.evaluate_policy( + mode="feature-branch", + changed_files=["dispatch_cli/main.py"], + current_pyproject=pyproject(version="0.5.1"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="origin/main", + 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_feature_branch_docs_only_change_does_not_require_bump(): + result = version_policy.evaluate_policy( + mode="feature-branch", + changed_files=["README.md", ".github/workflows/release.yml"], + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="origin/main", + 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_feature_branch_docs_only_change_can_be_behind_main_version(): + result = version_policy.evaluate_policy( + mode="feature-branch", + changed_files=["README.md"], + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.1"), + baseline_ref="origin/main", + baseline_version="0.5.1", + ) + + assert result.requires_version_bump is False + assert result.failure_reason is None + + +def test_relevant_pyproject_change_requires_bump(): + result = version_policy.evaluate_policy( + mode="feature-branch", + changed_files=["pyproject.toml"], + current_pyproject=pyproject(version="0.5.0", description="Updated CLI"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="origin/main", + 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", + changed_files=["pyproject.toml"], + 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="origin/main", + 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_release_change_requires_bump_when_version_is_unchanged(): + result = version_policy.evaluate_policy( + mode="release", + changed_files=["dispatch_cli/main.py"], + 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", + changed_files=["dispatch_cli/main.py"], + 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", + changed_files=["README.md"], + 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", + changed_files=["README.md"], + 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_unknown_top_level_path_requires_bump(): + result = version_policy.evaluate_policy( + mode="feature-branch", + changed_files=["new-surface/config.json"], + current_pyproject=pyproject(version="0.5.0"), + baseline_pyproject=pyproject(version="0.5.0"), + baseline_ref="origin/main", + baseline_version="0.5.0", + ) + + assert result.unknown_paths == ("new-surface/config.json",) + 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", + changed_files=["dispatch_cli/main.py"], + 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 diff --git a/.github/workflows/ci-reusable.yml b/.github/workflows/ci-reusable.yml new file mode 100644 index 0000000..deb79c4 --- /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/test_version_policy.py + + 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..5c0dc41 --- /dev/null +++ b/.github/workflows/feature-branch.yml @@ -0,0 +1,22 @@ +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 + default_branch: main + + 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..740b126 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +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 + default_branch: main + + 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..eb1c467 --- /dev/null +++ b/.github/workflows/version-policy-reusable.yml @@ -0,0 +1,45 @@ +name: Reusable Version Policy + +on: + workflow_call: + inputs: + mode: + description: Version policy mode. + required: true + type: string + default_branch: + description: Default branch used for feature branch comparisons. + required: false + type: string + default: main + outputs: + current_tag: + description: Current version tag from pyproject.toml. + value: ${{ jobs.version_policy.outputs.current_tag }} + should_release: + description: Whether the release workflow should create a GitHub 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: 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 }}" + --default-branch "${{ inputs.default_branch }}" From 2f28971c892c36abe23c169254831adb1a806f4d Mon Sep 17 00:00:00 2001 From: Jack Burns Date: Mon, 16 Mar 2026 17:06:53 -0400 Subject: [PATCH 2/2] remove unused files --- .github/scripts/change_scope.py | 192 ++++++++++++++++ .github/scripts/ci_git.py | 76 +++++++ .github/scripts/version_policy.py | 212 +++--------------- .github/tests/test_change_scope.py | 163 ++++++++++++++ .github/tests/test_ci_git.py | 113 ++++++++++ .github/tests/test_version_policy.py | 100 +++++---- .github/workflows/ci-reusable.yml | 2 +- .github/workflows/feature-branch.yml | 1 - .github/workflows/release.yml | 1 - .github/workflows/version-policy-reusable.yml | 25 ++- 10 files changed, 653 insertions(+), 232 deletions(-) create mode 100644 .github/scripts/change_scope.py create mode 100644 .github/scripts/ci_git.py create mode 100644 .github/tests/test_change_scope.py create mode 100644 .github/tests/test_ci_git.py 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 index 59abe7c..5beded8 100644 --- a/.github/scripts/version_policy.py +++ b/.github/scripts/version_policy.py @@ -9,24 +9,13 @@ import subprocess import sys import tomllib -from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path from typing import Any -SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") -SEMVER_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +from ci_git import fetch_tags, get_latest_tag -IGNORED_PATH_PREFIXES = (".github/", "tests/", "tasks/") -IGNORED_PATHS = { - "README.md", - "CONTRIBUTING.md", - "NOTICE", - "LICENSE", - "LICENSE-3rdparty.csv", - "SKILL.md", - "uv.lock", -} +SEMVER_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") @dataclass(frozen=True) @@ -36,11 +25,11 @@ class PolicyResult: 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 - unknown_paths: tuple[str, ...] failure_reason: str | None @@ -51,18 +40,6 @@ def parse_version(version: str) -> tuple[int, int, int]: return tuple(int(part) for part in match.groups()) -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 compare_versions(current_version: str, baseline_version: str) -> int: current = parse_version(current_version) baseline = parse_version(baseline_version) @@ -73,23 +50,6 @@ def compare_versions(current_version: str, baseline_version: str) -> int: return 0 -def extract_shipped_path_prefixes(pyproject_data: dict[str, Any]) -> tuple[str, ...]: - packages = ( - pyproject_data.get("tool", {}) - .get("hatch", {}) - .get("build", {}) - .get("targets", {}) - .get("wheel", {}) - .get("packages", []) - ) - normalized = [] - for package in packages: - package_name = str(package).strip().strip("/") - if package_name: - normalized.append(f"{package_name}/") - return tuple(normalized) - - def is_relevant_pyproject_change( current_pyproject: dict[str, Any], baseline_pyproject: dict[str, Any] | None ) -> bool: @@ -121,35 +81,10 @@ def is_relevant_pyproject_change( ) -def is_shipped_path(path: str, shipped_path_prefixes: tuple[str, ...]) -> bool: - return path.startswith(shipped_path_prefixes) - - -def is_ignored_path(path: str) -> bool: - if path in IGNORED_PATHS: - return True - if path.startswith("LICENSE"): - return True - return path.startswith(IGNORED_PATH_PREFIXES) - - -def requires_version_bump( - changed_files: Iterable[str], - relevant_pyproject_changed: bool, - unknown_paths: Iterable[str], - shipped_path_prefixes: tuple[str, ...], -) -> bool: - if relevant_pyproject_changed: - return True - return any( - is_shipped_path(path, shipped_path_prefixes) for path in changed_files - ) or any(unknown_paths) - - def evaluate_policy( *, mode: str, - changed_files: Iterable[str], + source_changed: bool, current_pyproject: dict[str, Any], baseline_pyproject: dict[str, Any] | None, baseline_ref: str, @@ -157,25 +92,10 @@ def evaluate_policy( ) -> PolicyResult: current_version = current_pyproject["project"]["version"] current_tag = f"v{current_version}" - shipped_path_prefixes = extract_shipped_path_prefixes(current_pyproject) relevant_change = is_relevant_pyproject_change( current_pyproject, baseline_pyproject ) - changed_files = list(changed_files) - unknown_paths = tuple( - path - for path in changed_files - if not is_ignored_path(path) - and not is_shipped_path(path, shipped_path_prefixes) - and path != "pyproject.toml" - ) - bump_required = requires_version_bump( - changed_files, - relevant_change, - unknown_paths, - shipped_path_prefixes, - ) - + bump_required = source_changed or relevant_change comparison = ( 1 if baseline_version is None @@ -193,13 +113,12 @@ def evaluate_policy( "Changes require a semantic version bump, " f"but {current_tag} is not greater than {baseline_ref}." ) - else: - if 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}." - ) + 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, @@ -207,79 +126,15 @@ def evaluate_policy( 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, - unknown_paths=unknown_paths, failure_reason=failure_reason, ) -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 fetch_default_branch(default_branch: str) -> None: - subprocess.run( - ["git", "fetch", "--force", "origin", default_branch], - check=True, - capture_output=True, - text=True, - ) - - -def fetch_tags() -> None: - subprocess.run( - ["git", "fetch", "--force", "--tags", "origin"], - 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(default_branch: str) -> str: - return run_git("merge-base", "HEAD", f"origin/{default_branch}") - - -def get_changed_files_since_ref(ref: str) -> list[str]: - output = run_git( - "diff", - "--name-only", - "--diff-filter=ACDMRTUXB", - f"{ref}..HEAD", - ) - return [line.strip() for line in output.splitlines() if line.strip()] - - -def get_changed_files_since_tag(latest_tag: str | None) -> list[str]: - if latest_tag is None: - output = run_git("ls-files") - else: - output = run_git( - "diff", - "--name-only", - "--diff-filter=ACDMRTUXB", - f"{latest_tag}...HEAD", - ) - return [line.strip() for line in output.splitlines() if line.strip()] - - def load_pyproject(path: Path) -> dict[str, Any]: with path.open("rb") as file_obj: return tomllib.load(file_obj) @@ -310,6 +165,7 @@ def write_github_outputs(result: PolicyResult) -> None: "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(), @@ -330,9 +186,15 @@ def parse_args() -> argparse.Namespace: help="Execution mode for workflow messaging.", ) parser.add_argument( - "--default-branch", - default="main", - help="Default branch name used for feature-branch comparisons.", + "--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() @@ -340,34 +202,33 @@ def parse_args() -> argparse.Namespace: 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": - fetch_default_branch(args.default_branch) - baseline_ref = get_merge_base(args.default_branch) - changed_files = get_changed_files_since_ref(baseline_ref) - baseline_pyproject = load_pyproject_from_ref(baseline_ref, repo_root) + 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"]) ) - baseline_label = f"origin/{args.default_branch}" else: - fetch_tags() - latest_tag = get_latest_tag() - changed_files = get_changed_files_since_tag(latest_tag) - baseline_pyproject = load_pyproject_from_ref(latest_tag, repo_root) + 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 - baseline_label = baseline_ref result = evaluate_policy( mode=args.mode, - changed_files=changed_files, + source_changed=source_changed, current_pyproject=current_pyproject, baseline_pyproject=baseline_pyproject, - baseline_ref=baseline_label, + baseline_ref=baseline_ref, baseline_version=baseline_version, ) write_github_outputs(result) @@ -375,14 +236,11 @@ def main() -> int: 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.unknown_paths: - print(" unknown paths conservatively treated as release-relevant:") - for path in result.unknown_paths: - print(f" - {path}") - if result.failure_reason: print(result.failure_reason, file=sys.stderr) return 1 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 index cfdbe3d..49d4b04 100644 --- a/.github/tests/test_version_policy.py +++ b/.github/tests/test_version_policy.py @@ -3,13 +3,17 @@ 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 @@ -23,7 +27,6 @@ def pyproject( *, version: str, description: str = "Dispatch CLI", - packages: list[str] | None = None, dependency_sources: dict[str, dict[str, str]] | None = None, dev_dependencies: list[str] | None = None, ) -> dict[str, object]: @@ -41,9 +44,7 @@ def pyproject( }, "tool": { "hatch": { - "build": { - "targets": {"wheel": {"packages": packages or ["dispatch_cli"]}} - }, + "build": {"targets": {"wheel": {"packages": ["dispatch_cli"]}}}, "metadata": {"allow-direct-references": True}, }, "uv": { @@ -63,35 +64,30 @@ def pyproject( } -def test_extract_shipped_path_prefixes_from_hatch_packages(): - assert version_policy.extract_shipped_path_prefixes( - pyproject(version="0.5.0", packages=["dispatch_cli", "other_pkg"]) - ) == ("dispatch_cli/", "other_pkg/") - - -def test_feature_branch_sdk_change_requires_bump_when_version_is_unchanged(): +def test_release_relevant_change_requires_bump_when_version_is_unchanged(): result = version_policy.evaluate_policy( mode="feature-branch", - changed_files=["dispatch_cli/main.py"], + source_changed=True, current_pyproject=pyproject(version="0.5.0"), baseline_pyproject=pyproject(version="0.5.0"), - baseline_ref="origin/main", + 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_feature_branch_sdk_change_passes_with_higher_version(): +def test_release_relevant_change_passes_with_higher_version(): result = version_policy.evaluate_policy( mode="feature-branch", - changed_files=["dispatch_cli/main.py"], + source_changed=True, current_pyproject=pyproject(version="0.5.1"), baseline_pyproject=pyproject(version="0.5.0"), - baseline_ref="origin/main", + baseline_ref="abc123", baseline_version="0.5.0", ) @@ -101,13 +97,13 @@ def test_feature_branch_sdk_change_passes_with_higher_version(): assert result.failure_reason is None -def test_feature_branch_docs_only_change_does_not_require_bump(): +def test_docs_only_change_does_not_require_bump(): result = version_policy.evaluate_policy( mode="feature-branch", - changed_files=["README.md", ".github/workflows/release.yml"], + source_changed=False, current_pyproject=pyproject(version="0.5.0"), baseline_pyproject=pyproject(version="0.5.0"), - baseline_ref="origin/main", + baseline_ref="abc123", baseline_version="0.5.0", ) @@ -117,27 +113,13 @@ def test_feature_branch_docs_only_change_does_not_require_bump(): 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", - changed_files=["README.md"], - current_pyproject=pyproject(version="0.5.0"), - baseline_pyproject=pyproject(version="0.5.1"), - baseline_ref="origin/main", - baseline_version="0.5.1", - ) - - assert result.requires_version_bump is False - assert result.failure_reason is None - - def test_relevant_pyproject_change_requires_bump(): result = version_policy.evaluate_policy( mode="feature-branch", - changed_files=["pyproject.toml"], + source_changed=False, current_pyproject=pyproject(version="0.5.0", description="Updated CLI"), baseline_pyproject=pyproject(version="0.5.0"), - baseline_ref="origin/main", + baseline_ref="abc123", baseline_version="0.5.0", ) @@ -149,13 +131,13 @@ def test_relevant_pyproject_change_requires_bump(): def test_dev_tooling_pyproject_change_does_not_require_bump(): result = version_policy.evaluate_policy( mode="feature-branch", - changed_files=["pyproject.toml"], + 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="origin/main", + baseline_ref="abc123", baseline_version="0.5.0", ) @@ -164,10 +146,24 @@ def test_dev_tooling_pyproject_change_does_not_require_bump(): 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", - changed_files=["dispatch_cli/main.py"], + source_changed=True, current_pyproject=pyproject(version="0.5.0"), baseline_pyproject=pyproject(version="0.4.9"), baseline_ref="v0.5.0", @@ -183,7 +179,7 @@ def test_release_change_requires_bump_when_version_is_unchanged(): def test_release_change_passes_with_higher_version(): result = version_policy.evaluate_policy( mode="release", - changed_files=["dispatch_cli/main.py"], + source_changed=True, current_pyproject=pyproject(version="0.5.1"), baseline_pyproject=pyproject(version="0.5.0"), baseline_ref="v0.5.0", @@ -199,7 +195,7 @@ def test_release_change_passes_with_higher_version(): def test_release_docs_only_change_does_not_require_release(): result = version_policy.evaluate_policy( mode="release", - changed_files=["README.md"], + source_changed=False, current_pyproject=pyproject(version="0.5.0"), baseline_pyproject=pyproject(version="0.5.0"), baseline_ref="v0.5.0", @@ -214,7 +210,7 @@ def test_release_docs_only_change_does_not_require_release(): def test_release_lower_than_latest_tag_fails(): result = version_policy.evaluate_policy( mode="release", - changed_files=["README.md"], + source_changed=False, current_pyproject=pyproject(version="0.4.9"), baseline_pyproject=pyproject(version="0.5.0"), baseline_ref="v0.5.0", @@ -226,17 +222,16 @@ def test_release_lower_than_latest_tag_fails(): ) -def test_unknown_top_level_path_requires_bump(): +def test_workflow_classified_release_relevant_change_requires_bump(): result = version_policy.evaluate_policy( mode="feature-branch", - changed_files=["new-surface/config.json"], + source_changed=True, current_pyproject=pyproject(version="0.5.0"), baseline_pyproject=pyproject(version="0.5.0"), - baseline_ref="origin/main", + baseline_ref="abc123", baseline_version="0.5.0", ) - assert result.unknown_paths == ("new-surface/config.json",) assert result.requires_version_bump is True assert result.failure_reason is not None @@ -244,7 +239,7 @@ def test_unknown_top_level_path_requires_bump(): def test_release_without_prior_tag_triggers_initial_release(): result = version_policy.evaluate_policy( mode="release", - changed_files=["dispatch_cli/main.py"], + source_changed=True, current_pyproject=pyproject(version="0.1.0"), baseline_pyproject=None, baseline_ref="initial repository state", @@ -255,3 +250,16 @@ def test_release_without_prior_tag_triggers_initial_release(): 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 index deb79c4..a8eb701 100644 --- a/.github/workflows/ci-reusable.yml +++ b/.github/workflows/ci-reusable.yml @@ -43,7 +43,7 @@ jobs: - 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/test_version_policy.py + run: uv run pytest --cov=dispatch_cli --cov-report=term-missing tests .github/tests build: name: Build diff --git a/.github/workflows/feature-branch.yml b/.github/workflows/feature-branch.yml index 5c0dc41..afc0519 100644 --- a/.github/workflows/feature-branch.yml +++ b/.github/workflows/feature-branch.yml @@ -14,7 +14,6 @@ jobs: uses: ./.github/workflows/version-policy-reusable.yml with: mode: feature-branch - default_branch: main ci: name: CI diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 740b126..120d23a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,6 @@ jobs: uses: ./.github/workflows/version-policy-reusable.yml with: mode: release - default_branch: main ci: name: CI diff --git a/.github/workflows/version-policy-reusable.yml b/.github/workflows/version-policy-reusable.yml index eb1c467..a5f2055 100644 --- a/.github/workflows/version-policy-reusable.yml +++ b/.github/workflows/version-policy-reusable.yml @@ -7,17 +7,22 @@ on: description: Version policy mode. required: true type: string - default_branch: - description: Default branch used for feature branch comparisons. + ignored_paths_json: + description: JSON array of exact paths ignored by release-scope classification. required: false type: string - default: main + 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 from pyproject.toml. + description: Current version tag derived from pyproject version. value: ${{ jobs.version_policy.outputs.current_tag }} should_release: - description: Whether the release workflow should create a GitHub Release. + description: Whether the current main commit should produce a release. value: ${{ jobs.version_policy.outputs.should_release }} permissions: @@ -35,6 +40,13 @@ jobs: 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 @@ -42,4 +54,5 @@ jobs: run: > uv run python .github/scripts/version_policy.py --mode "${{ inputs.mode }}" - --default-branch "${{ inputs.default_branch }}" + --source-changed "${{ steps.change_scope.outputs.source_changed }}" + --pyproject-baseline-ref "${{ steps.change_scope.outputs.pyproject_baseline_ref }}"