diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 315513c0..84e6b785 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -90,34 +90,12 @@ jobs: - name: Classify trigger id: classify env: - TAG: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | - RELEASE=0 - DEV=0 - BUILD_TEST=0 - VERSION=Test - if [[ '${{ github.ref_type }}' == 'tag' ]]; then - if [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - if git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then - RELEASE=1 - VERSION="$TAG" - else - echo "::error::Stable tag $TAG does not point to a commit on main; refusing to release" - exit 1 - fi - elif [[ "$TAG" == *'alpha'* || "$TAG" == *'beta'* ]]; then - RELEASE=1 - DEV=1 - VERSION="$TAG" - fi - elif [[ '${{ github.event_name }}' == 'pull_request' && "$HEAD_REF" == *'dev'* ]]; then - BUILD_TEST=1 - fi - echo "release=$RELEASE" >> $GITHUB_OUTPUT - echo "dev=$DEV" >> $GITHUB_OUTPUT - echo "build_test=$BUILD_TEST" >> $GITHUB_OUTPUT - echo "version=$VERSION" >> $GITHUB_OUTPUT + PR_TITLE: ${{ github.event.pull_request.title }} + run: python3 scripts/classify_release.py --output "$GITHUB_OUTPUT" - name: Check result run: | echo "release: ${{ steps.classify.outputs.release }}" diff --git a/backend/src/test/test_release_classification.py b/backend/src/test/test_release_classification.py new file mode 100644 index 00000000..16972f3a --- /dev/null +++ b/backend/src/test/test_release_classification.py @@ -0,0 +1,110 @@ +"""Contract tests for the release-trigger classifier used by GitHub Actions.""" + +import runpy +from pathlib import Path + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "classify_release.py" +SCRIPT = runpy.run_path(str(SCRIPT_PATH)) +classify_release = SCRIPT["classify_release"] +ReleaseClassificationError = SCRIPT["ReleaseClassificationError"] + +NO_RELEASE = { + "release": "0", + "dev": "0", + "build_test": "0", + "version": "Test", +} + + +@pytest.mark.parametrize( + ("event_name", "ref_type", "ref_name", "pr_head", "pr_title", "expected"), + [ + ("push", "branch", "main", "", "", NO_RELEASE), + ( + "pull_request", + "branch", + "42/merge", + "Doc-update", + "Doc update", + NO_RELEASE, + ), + ( + "pull_request", + "branch", + "42/merge", + "3.4-dev", + "Doc update", + {**NO_RELEASE, "build_test": "1"}, + ), + ("push", "tag", "Doc-update", "", "", NO_RELEASE), + ], +) +def test_non_release_events_never_derive_version_from_pr_metadata( + event_name, ref_type, ref_name, pr_head, pr_title, expected +): + assert ( + classify_release( + event_name=event_name, + ref_type=ref_type, + ref_name=ref_name, + pr_head=pr_head, + pr_title=pr_title, + ) + == expected + ) + + +def test_stable_semver_tag_on_main_is_a_stable_release(): + assert classify_release( + event_name="push", + ref_type="tag", + ref_name="3.3.4", + stable_tag_on_main=True, + ) == { + "release": "1", + "dev": "0", + "build_test": "0", + "version": "3.3.4", + } + + +def test_stable_semver_tag_off_main_is_rejected(): + with pytest.raises( + ReleaseClassificationError, + match="does not point to a commit on main", + ): + classify_release( + event_name="push", + ref_type="tag", + ref_name="3.3.4", + stable_tag_on_main=False, + ) + + +def test_beta_semver_tag_is_a_dev_release(): + assert classify_release( + event_name="push", + ref_type="tag", + ref_name="3.4.0-beta.1", + ) == { + "release": "1", + "dev": "1", + "build_test": "0", + "version": "3.4.0-beta.1", + } + + +@pytest.mark.parametrize("pr_title", ["Doc update", "3.3.4", "3.4.0-beta.1"]) +def test_pr_title_is_never_a_version_source(pr_title): + result = classify_release( + event_name="pull_request", + ref_type="branch", + ref_name="1074/merge", + pr_head="docs/update", + pr_title=pr_title, + ) + + assert result["version"] == "Test" + assert result["release"] == "0" diff --git a/docs/superpowers/plans/2026-07-10-issue-driven-e2e-implementation-plan.md b/docs/superpowers/plans/2026-07-10-issue-driven-e2e-implementation-plan.md index c04fc570..43f1bdbe 100644 --- a/docs/superpowers/plans/2026-07-10-issue-driven-e2e-implementation-plan.md +++ b/docs/superpowers/plans/2026-07-10-issue-driven-e2e-implementation-plan.md @@ -160,7 +160,6 @@ cd backend && uv run black --check src/module/parser/analyser/tmdb_parser.py src cd backend && uv run pytest \ src/test/test_torrent_parser.py \ src/test/test_title_parser.py \ - src/test/test_offset_detector.py \ src/test/test_offset_scanner.py \ src/test/test_issue_bugs.py -q cd backend && uv run ruff check src/module/parser/analyser/raw_parser.py \ @@ -195,8 +194,8 @@ cd backend && uv run ruff check src/module/parser/analyser/raw_parser.py \ cd backend && uv run pytest src/test/test_release_classification.py -q cd backend && uv run ruff check ../scripts/classify_release.py \ src/test/test_release_classification.py -python scripts/classify_release.py --event pull_request --ref-type branch \ - --ref-name main --head-ref Doc-update --on-main false +python3 scripts/classify_release.py --event-name pull_request --ref-type branch \ + --ref-name main --pr-head Doc-update --pr-title "3.3.4" ``` ## Task 5: Build Deterministic Fixture Assets diff --git a/scripts/classify_release.py b/scripts/classify_release.py new file mode 100644 index 00000000..b32979e5 --- /dev/null +++ b/scripts/classify_release.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Classify a GitHub Actions trigger without deriving versions from PR data.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +STABLE_TAG = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +PRERELEASE_TAG = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+-(?:alpha|beta)\.[0-9]+$") + + +class ReleaseClassificationError(ValueError): + """Raised when a release-shaped trigger is unsafe or unverifiable.""" + + +def classify_release( + *, + event_name: str, + ref_type: str, + ref_name: str, + pr_head: str = "", + pr_title: str = "", + stable_tag_on_main: bool | None = None, +) -> dict[str, str]: + """Return the four outputs consumed by the release workflow. + + ``pr_title`` is accepted so callers can pass complete event metadata, but + is deliberately never used as a version source. + """ + del pr_title + result = { + "release": "0", + "dev": "0", + "build_test": "0", + "version": "Test", + } + + if ref_type == "tag" and STABLE_TAG.fullmatch(ref_name): + if stable_tag_on_main is None: + raise ReleaseClassificationError( + f"Stable tag {ref_name} ancestry was not verified" + ) + if not stable_tag_on_main: + raise ReleaseClassificationError( + f"Stable tag {ref_name} does not point to a commit on main; " + "refusing to release" + ) + return { + **result, + "release": "1", + "version": ref_name, + } + + if ref_type == "tag" and PRERELEASE_TAG.fullmatch(ref_name): + return { + **result, + "release": "1", + "dev": "1", + "version": ref_name, + } + + if event_name == "pull_request" and "dev" in pr_head: + result["build_test"] = "1" + + return result + + +def _is_main_ancestor(sha: str, main_ref: str) -> bool: + if not sha: + raise ReleaseClassificationError( + "GITHUB_SHA is required to verify a stable release tag" + ) + completed = subprocess.run( + ["git", "merge-base", "--is-ancestor", sha, main_ref], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode == 0: + return True + if completed.returncode == 1: + return False + detail = completed.stderr.strip() or "git merge-base failed" + raise ReleaseClassificationError( + f"Unable to verify stable tag ancestry against {main_ref}: {detail}" + ) + + +def _write_outputs(outputs: dict[str, str], destination: str) -> None: + payload = "".join(f"{key}={value}\n" for key, value in outputs.items()) + if destination: + with Path(destination).open("a", encoding="utf-8") as output_file: + output_file.write(payload) + else: + sys.stdout.write(payload) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--event-name", default=os.environ.get("EVENT_NAME", "")) + parser.add_argument("--ref-type", default=os.environ.get("REF_TYPE", "")) + parser.add_argument("--ref-name", default=os.environ.get("REF_NAME", "")) + parser.add_argument("--pr-head", default=os.environ.get("HEAD_REF", "")) + parser.add_argument("--pr-title", default=os.environ.get("PR_TITLE", "")) + parser.add_argument("--sha", default=os.environ.get("GITHUB_SHA", "")) + parser.add_argument("--main-ref", default="origin/main") + parser.add_argument( + "--output", + default=os.environ.get("GITHUB_OUTPUT", ""), + help="GitHub Actions output file; stdout when omitted", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + try: + stable_tag_on_main = None + if args.ref_type == "tag" and STABLE_TAG.fullmatch(args.ref_name): + stable_tag_on_main = _is_main_ancestor(args.sha, args.main_ref) + outputs = classify_release( + event_name=args.event_name, + ref_type=args.ref_type, + ref_name=args.ref_name, + pr_head=args.pr_head, + pr_title=args.pr_title, + stable_tag_on_main=stable_tag_on_main, + ) + _write_outputs(outputs, args.output) + except ReleaseClassificationError as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())