From de55f98452902f46ea860e4757fbe028cf637b97 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Thu, 20 Nov 2025 11:54:54 +0200 Subject: [PATCH 01/13] Introduce package specific factories --- src/redis_release/bht/behaviours.py | 50 ------- src/redis_release/bht/ppas.py | 10 +- src/redis_release/bht/tree_factory.py | 185 ++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 54 deletions(-) create mode 100644 src/redis_release/bht/tree_factory.py diff --git a/src/redis_release/bht/behaviours.py b/src/redis_release/bht/behaviours.py index 738e624..c6dbbdd 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -715,56 +715,6 @@ def update(self) -> Status: return Status.SUCCESS -def create_prepare_build_workflow_inputs( - name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Behaviour: - cls_map = { - PackageType.DEBIAN: DebianWorkflowInputs, - } - - selected_class = ( - cls_map.get(package_meta.package_type, GenericWorkflowInputs) - if package_meta.package_type - else GenericWorkflowInputs - ) - return selected_class( - name, - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ) - - -def create_prepare_publish_workflow_inputs( - name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Behaviour: - cls_map = { - PackageType.DEBIAN: DebianWorkflowInputs, - } - - selected_class = ( - cls_map.get(package_meta.package_type, GenericWorkflowInputs) - if package_meta.package_type - else GenericWorkflowInputs - ) - return selected_class( - name, - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ) - - class DebianWorkflowInputs(ReleaseAction): def __init__( self, diff --git a/src/redis_release/bht/ppas.py b/src/redis_release/bht/ppas.py index a1b5a39..a010400 100644 --- a/src/redis_release/bht/ppas.py +++ b/src/redis_release/bht/ppas.py @@ -23,8 +23,6 @@ IsWorkflowIdentified, IsWorkflowSuccessful, IsWorkflowTriggered, - create_prepare_build_workflow_inputs, - create_prepare_publish_workflow_inputs, ) from .composites import ( DownloadArtifactsListGuarded, @@ -35,6 +33,10 @@ WaitForWorkflowCompletion, ) from .state import PackageMeta, ReleaseMeta, Workflow +from .tree_factory import ( + create_build_workflow_inputs_behaviour, + create_publish_workflow_inputs_behaviour, +) def create_workflow_success_ppa( @@ -220,7 +222,7 @@ def create_build_workflow_inputs_ppa( ) -> Union[Selector, Sequence]: return create_PPA( "Set Build Workflow Inputs", - create_prepare_build_workflow_inputs( + create_build_workflow_inputs_behaviour( "Set Build Workflow Inputs", workflow, package_meta, @@ -238,7 +240,7 @@ def create_publish_workflow_inputs_ppa( ) -> Union[Selector, Sequence]: return create_PPA( "Set Publish Workflow Inputs", - create_prepare_publish_workflow_inputs( + create_publish_workflow_inputs_behaviour( "Set Publish Workflow Inputs", workflow, package_meta, diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py new file mode 100644 index 0000000..7bde9b9 --- /dev/null +++ b/src/redis_release/bht/tree_factory.py @@ -0,0 +1,185 @@ +""" +Package-specific tree factories. + +This module provides factories for creating package-type-specific behaviors +and tree branches. Each package type gets its own factory class that knows +how to create all the specialized behaviors and tree structures for that type. +""" + +from abc import ABC, abstractmethod +from typing import Dict, Optional + +from py_trees.behaviour import Behaviour + +from ..models import PackageType +from .behaviours import GenericWorkflowInputs +from .state import PackageMeta, ReleaseMeta, Workflow + + +class TreeFactory(ABC): + """Abstract base class for package-specific tree factories. + + Subclasses can override specific methods to customize behavior for + different package types. Methods not overridden will use the default + implementation from GenericFactory. + """ + + @abstractmethod + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + """Create behavior for preparing build workflow inputs.""" + pass + + @abstractmethod + def create_publish_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + """Create behavior for preparing publish workflow inputs.""" + pass + + # Add more methods as you need different behaviors or tree branches + # @abstractmethod + # def create_artifact_handler(self, ...) -> Behaviour: + # """Create behavior for handling package artifacts.""" + # pass + # + # @abstractmethod + # def create_validation_branch(self, ...) -> Sequence: + # """Create a tree branch for package validation.""" + # pass + + +class GenericFactory(TreeFactory): + """Default factory for packages without specific customizations. + + This provides the base implementation that other factories can inherit + and selectively override. + """ + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return GenericWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_publish_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return GenericWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + +class DebianFactory(GenericFactory): + """Factory for Debian packages. + + Inherits from GenericFactory and overrides only the methods that need + Debian-specific behavior. + """ + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + from .behaviours import DebianWorkflowInputs + + return DebianWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_publish_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + from .behaviours import DebianWorkflowInputs + + return DebianWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + +class DockerFactory(GenericFactory): + """Factory for Docker packages. + + Currently uses generic implementations. Override methods as needed + when Docker-specific behavior is required. + """ + + pass # Inherits all methods from GenericFactory + + +# Factory registry +_FACTORIES: Dict[PackageType, TreeFactory] = { + PackageType.DEBIAN: DebianFactory(), + PackageType.DOCKER: DockerFactory(), +} + +_DEFAULT_FACTORY = GenericFactory() + + +def get_factory(package_type: Optional[PackageType]) -> TreeFactory: + """Get the factory for a given package type. + + Args: + package_type: The package type to get factory for + + Returns: + TreeFactory instance for the given type, or default factory if not found + """ + if package_type is None: + return _DEFAULT_FACTORY + return _FACTORIES.get(package_type, _DEFAULT_FACTORY) + + +def create_build_workflow_inputs_behaviour( + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, +) -> Behaviour: + return get_factory(package_meta.package_type).create_build_workflow_inputs( + name, workflow, package_meta, release_meta, log_prefix + ) + + +def create_publish_workflow_inputs_behaviour( + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, +) -> Behaviour: + return get_factory(package_meta.package_type).create_publish_workflow_inputs( + name, workflow, package_meta, release_meta, log_prefix + ) From 9a08237e8e5b4aae89f986d8c6d9ae3a583ae169 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Thu, 20 Nov 2025 15:02:59 +0200 Subject: [PATCH 02/13] Introduce package specific meta classes --- src/redis_release/bht/state.py | 74 ++++++++++++++++++++++++++-------- src/redis_release/config.py | 2 +- src/redis_release/models.py | 31 ++++++++++++++ 3 files changed, 90 insertions(+), 17 deletions(-) diff --git a/src/redis_release/bht/state.py b/src/redis_release/bht/state.py index 2c57feb..19f497f 100644 --- a/src/redis_release/bht/state.py +++ b/src/redis_release/bht/state.py @@ -2,15 +2,17 @@ import logging from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Literal, Optional, Union from py_trees import common from py_trees.common import Status from pydantic import BaseModel, Field from redis_release.models import ( + HomebrewChannel, PackageType, ReleaseType, + SnapRiskLevel, WorkflowConclusion, WorkflowStatus, WorkflowType, @@ -113,8 +115,9 @@ class PackageMetaEphemeral(BaseModel): class PackageMeta(BaseModel): - """Metadata for a package.""" + """Metadata for a package (base/generic type).""" + serialization_hint: Literal["generic"] = "generic" package_type: Optional[PackageType] = None repo: str = "" ref: Optional[str] = None @@ -122,10 +125,33 @@ class PackageMeta(BaseModel): ephemeral: PackageMetaEphemeral = Field(default_factory=PackageMetaEphemeral) +class HomebrewMeta(PackageMeta): + """Metadata for Homebrew package.""" + + serialization_hint: Literal["homebrew"] = "homebrew" # type: ignore[assignment] + homebrew_channel: Optional[HomebrewChannel] = None + + +class SnapMeta(PackageMeta): + """Metadata for Snap package.""" + + serialization_hint: Literal["snap"] = "snap" # type: ignore[assignment] + snap_risk_level: Optional[SnapRiskLevel] = None + + class Package(BaseModel): - """State for a package in the release.""" + """State for a package in the release. - meta: PackageMeta = Field(default_factory=PackageMeta) + The meta field uses a discriminated union based on the serialization_hint field. + This ensures correct deserialization: + - serialization_hint="generic" -> PackageMeta + - serialization_hint="homebrew" -> HomebrewMeta + - serialization_hint="snap" -> SnapMeta + """ + + meta: Union[HomebrewMeta, SnapMeta, PackageMeta] = Field( + default_factory=PackageMeta, discriminator="serialization_hint" + ) build: Workflow = Field(default_factory=Workflow) publish: Workflow = Field(default_factory=Workflow) @@ -160,11 +186,6 @@ def from_config(cls, config: Config) -> "ReleaseState": """Build ReleaseState from config with default values.""" packages = {} for package_name, package_config in config.packages.items(): - if not isinstance(package_config.package_type, PackageType): - raise ValueError( - f"Package '{package_name}': package_type must be a PackageType, " - f"got {type(package_config.package_type).__name__}" - ) # Validate and get build workflow file if not isinstance(package_config.build_workflow, str): raise ValueError( @@ -187,13 +208,34 @@ def from_config(cls, config: Config) -> "ReleaseState": f"Package '{package_name}': publish_workflow cannot be empty" ) - # Initialize package metadata - package_meta = PackageMeta( - repo=package_config.repo, - ref=package_config.ref, - package_type=package_config.package_type, - publish_internal_release=package_config.publish_internal_release, - ) + # Initialize package metadata - create appropriate subclass based on package_type + package_meta: Union[HomebrewMeta, SnapMeta, PackageMeta] + if package_config.package_type == PackageType.HOMEBREW: + package_meta = HomebrewMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + elif package_config.package_type == PackageType.SNAP: + package_meta = SnapMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + elif package_config.package_type is not None: + package_meta = PackageMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + else: + raise ValueError( + f"Package '{package_name}': package_type must be a PackageType, " + f"got {type(package_config.package_type).__name__}" + ) # Initialize build workflow build_workflow = Workflow( diff --git a/src/redis_release/config.py b/src/redis_release/config.py index acb03ff..141d774 100644 --- a/src/redis_release/config.py +++ b/src/redis_release/config.py @@ -6,7 +6,7 @@ import yaml from pydantic import BaseModel, Field -from .models import PackageType +from .models import HomebrewChannel, PackageType, SnapRiskLevel class PackageConfig(BaseModel): diff --git a/src/redis_release/models.py b/src/redis_release/models.py index 6ccf5c0..337313a 100644 --- a/src/redis_release/models.py +++ b/src/redis_release/models.py @@ -19,6 +19,37 @@ class PackageType(str, Enum): DOCKER = "docker" DEBIAN = "debian" + RPM = "rpm" + HOMEBREW = "homebrew" + SNAP = "snap" + + +class PackageSerializationType(str, Enum): + """Package serialization type for discriminated union. + + This enum is used as a discriminator field for Pydantic's discriminated union + to correctly deserialize different PackageMeta subclasses. + """ + + DEFAULT = "default" # For generic PackageMeta + HOMEBREW = "homebrew" # For HomebrewMeta + SNAP = "snap" # For SnapMeta + + +class HomebrewChannel(str, Enum): + """Homebrew channel enumeration.""" + + STABLE = "stable" + RC = "rc" + + +class SnapRiskLevel(str, Enum): + """Snap channel enumeration.""" + + STABLE = "stable" + CANDIDATE = "candidate" + BETA = "beta" + EDGE = "edge" class ReleaseType(str, Enum): From 545eb707bc36a11b559d959c1317bb7c80f10b61 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 10:54:05 +0200 Subject: [PATCH 03/13] Reorganize tree building with factory classes --- src/redis_release/bht/behaviours.py | 190 ++++++++++- src/redis_release/bht/ppas.py | 42 +-- src/redis_release/bht/state.py | 100 ++++-- src/redis_release/bht/tree.py | 247 +------------- src/redis_release/bht/tree_factory.py | 458 ++++++++++++++++++++++---- src/redis_release/models.py | 50 +++ src/redis_release/state_manager.py | 3 +- 7 files changed, 707 insertions(+), 383 deletions(-) diff --git a/src/redis_release/bht/behaviours.py b/src/redis_release/bht/behaviours.py index c6dbbdd..598ab5c 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -26,6 +26,7 @@ from ..github_client_async import GitHubClientAsync from ..models import ( + HomebrewChannel, PackageType, RedisVersion, ReleaseType, @@ -33,7 +34,7 @@ WorkflowStatus, ) from .logging_wrapper import PyTreesLoggerWrapper -from .state import Package, PackageMeta, ReleaseMeta, Workflow +from .state import HomebrewMeta, Package, PackageMeta, ReleaseMeta, Workflow logger = logging.getLogger(__name__) @@ -737,6 +738,193 @@ def update(self) -> Status: return Status.SUCCESS +class DetectHomebrewChannel(ReleaseAction): + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.package_meta = package_meta + self.release_meta = release_meta + super().__init__(name=name, log_prefix=log_prefix) + + def initialise(self) -> None: + if self.release_meta.tag is None: + self.logger.error("Release tag is not set") + return + try: + self.release_version = RedisVersion.parse(self.release_meta.tag) + except ValueError as e: + self.logger.error(f"Failed to parse release tag: {e}") + return + + def update(self) -> Status: + if self.release_meta.tag is None: + return Status.FAILURE + + msg = "" + if self.release_version.is_rc: + self.package_meta.homebrew_channel = HomebrewChannel.RC + msg = "Homebrew channel detected: rc" + elif self.release_version.is_ga: + msg = "Homebrew channel detected: stable" + self.package_meta.homebrew_channel = HomebrewChannel.STABLE + else: + msg = "Homebrew channel not detected" + + if self.log_once( + "homebrew_channel_detected", self.package_meta.ephemeral.log_once_flags + ): + self.logger.info(msg) + + return Status.SUCCESS + + +class ClassifyHomebrewVersion(ReleaseAction): + """Classify Homebrew version by downloading and parsing the cask file. + + This behavior downloads the appropriate Homebrew cask file (redis.rb or redis-rc.rb) + based on the homebrew_channel, extracts the version, and compares it with the + release tag version to determine if the version is acceptable. + """ + + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str = "", + ) -> None: + self.package_meta = package_meta + self.release_meta = release_meta + self.github_client = github_client + self.task: Optional[asyncio.Task] = None + self.release_version: Optional[RedisVersion] = None + self.cask_version: Optional[RedisVersion] = None + super().__init__(name=name, log_prefix=log_prefix) + + def initialise(self) -> None: + """Initialize by validating inputs and starting download task.""" + # Validate homebrew_channel is set + if self.package_meta.homebrew_channel is None: + self.logger.error("Homebrew channel is not set") + return + + # Validate repo and ref are set + if not self.package_meta.repo: + self.logger.error("Package repository is not set") + return + + if not self.package_meta.ref: + self.logger.error("Package ref is not set") + return + + # Parse release version from tag + if self.release_meta.tag is None: + self.logger.error("Release tag is not set") + return + + try: + self.release_version = RedisVersion.parse(self.release_meta.tag) + self.logger.debug(f"Parsed release version: {self.release_version}") + except ValueError as e: + self.logger.error(f"Failed to parse release tag: {e}") + return + + # Determine which cask file to download based on channel + if self.package_meta.homebrew_channel == HomebrewChannel.STABLE: + cask_file = "Casks/redis.rb" + elif self.package_meta.homebrew_channel == HomebrewChannel.RC: + cask_file = "Casks/redis-rc.rb" + else: + self.logger.error( + f"Unknown homebrew channel: {self.package_meta.homebrew_channel}" + ) + return + + self.logger.debug( + f"Downloading cask file: {cask_file} from {self.package_meta.repo}@{self.package_meta.ref}" + ) + + # Start async task to download the cask file from package repo and ref + self.task = asyncio.create_task( + self.github_client.download_file( + self.package_meta.repo, cask_file, self.package_meta.ref + ) + ) + + def update(self) -> Status: + """Process downloaded cask file and classify version.""" + try: + # Validate prerequisites + if self.package_meta.homebrew_channel is None: + return Status.FAILURE + + if self.release_version is None: + return Status.FAILURE + + assert self.task is not None + + # Wait for download to complete + if not self.task.done(): + return Status.RUNNING + + # Get the downloaded content + cask_content = self.task.result() + if cask_content is None: + self.logger.error("Failed to download cask file") + return Status.FAILURE + + # Parse version from cask file + # Look for: version "X.Y.Z" + import re + + version_match = re.search( + r'^\s*version\s+"([^"]+)"', cask_content, re.MULTILINE + ) + if not version_match: + self.logger.error("Could not find version declaration in cask file") + return Status.FAILURE + + version_str = version_match.group(1) + self.logger.debug(f"Found version in cask file: {version_str}") + + # Parse the cask version + try: + self.cask_version = RedisVersion.parse(version_str) + self.logger.info( + f"Cask version: {self.cask_version}, Release version: {self.release_version}" + ) + except ValueError as e: + self.logger.error(f"Failed to parse cask version '{version_str}': {e}") + return Status.FAILURE + + # Compare versions: cask version >= release version means acceptable + if self.cask_version >= self.release_version: # type: ignore[operator] + self.package_meta.ephemeral.is_version_acceptable = True + self.logger.info( + f"[green]Version acceptable:[/green] cask {self.cask_version} >= release {self.release_version}" + ) + else: + self.package_meta.ephemeral.is_version_acceptable = False + self.logger.info( + f"[yellow]Version not acceptable:[/yellow] cask {self.cask_version} < release {self.release_version}" + ) + + return Status.SUCCESS + + except Exception as e: + return self.log_exception_and_return_failure(e) + + def terminate(self, new_status: Status) -> None: + """Terminate the behavior.""" + # TODO: Cancel task if needed + pass + + ### Conditions ### diff --git a/src/redis_release/bht/ppas.py b/src/redis_release/bht/ppas.py index a010400..fc2157b 100644 --- a/src/redis_release/bht/ppas.py +++ b/src/redis_release/bht/ppas.py @@ -3,7 +3,7 @@ See backchain.py for more details on backchaining. -Chains are formed and latched in `tree.py` +Chains are formed and latched in `tree_factory.py` """ @@ -33,10 +33,6 @@ WaitForWorkflowCompletion, ) from .state import PackageMeta, ReleaseMeta, Workflow -from .tree_factory import ( - create_build_workflow_inputs_behaviour, - create_publish_workflow_inputs_behaviour, -) def create_workflow_success_ppa( @@ -212,39 +208,3 @@ def create_attach_release_handle_ppa( log_prefix=log_prefix, ), ) - - -def create_build_workflow_inputs_ppa( - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Union[Selector, Sequence]: - return create_PPA( - "Set Build Workflow Inputs", - create_build_workflow_inputs_behaviour( - "Set Build Workflow Inputs", - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ), - ) - - -def create_publish_workflow_inputs_ppa( - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Union[Selector, Sequence]: - return create_PPA( - "Set Publish Workflow Inputs", - create_publish_workflow_inputs_behaviour( - "Set Publish Workflow Inputs", - workflow, - package_meta, - release_meta, - log_prefix=log_prefix, - ), - ) diff --git a/src/redis_release/bht/state.py b/src/redis_release/bht/state.py index 19f497f..dbe80c3 100644 --- a/src/redis_release/bht/state.py +++ b/src/redis_release/bht/state.py @@ -18,7 +18,7 @@ WorkflowType, ) -from ..config import Config +from ..config import Config, PackageConfig logger = logging.getLogger(__name__) @@ -114,6 +114,30 @@ class PackageMetaEphemeral(BaseModel): log_once_flags: Dict[str, bool] = Field(default_factory=dict, exclude=True) +class HomebrewMetaEphemeral(PackageMetaEphemeral): + """Ephemeral metadata for Homebrew package. + + Extends base ephemeral metadata with Homebrew-specific fields. + """ + + classify_remote_versions: Optional[common.Status] = None + + is_version_acceptable: Optional[bool] = None + + +class SnapMetaEphemeral(PackageMetaEphemeral): + """Ephemeral metadata for Snap package. + + Extends base ephemeral metadata with Snap-specific fields. + """ + + classify_remote_versions: Optional[common.Status] = None + + is_version_acceptable: Optional[bool] = None + + pass + + class PackageMeta(BaseModel): """Metadata for a package (base/generic type).""" @@ -130,6 +154,7 @@ class HomebrewMeta(PackageMeta): serialization_hint: Literal["homebrew"] = "homebrew" # type: ignore[assignment] homebrew_channel: Optional[HomebrewChannel] = None + ephemeral: HomebrewMetaEphemeral = Field(default_factory=HomebrewMetaEphemeral) # type: ignore[assignment] class SnapMeta(PackageMeta): @@ -137,6 +162,7 @@ class SnapMeta(PackageMeta): serialization_hint: Literal["snap"] = "snap" # type: ignore[assignment] snap_risk_level: Optional[SnapRiskLevel] = None + ephemeral: SnapMetaEphemeral = Field(default_factory=SnapMetaEphemeral) # type: ignore[assignment] class Package(BaseModel): @@ -181,6 +207,47 @@ class ReleaseState(BaseModel): meta: ReleaseMeta = Field(default_factory=ReleaseMeta) packages: Dict[str, Package] = Field(default_factory=dict) + @staticmethod + def _create_package_meta_from_config( + package_config: "PackageConfig", + ) -> Union[HomebrewMeta, SnapMeta, PackageMeta]: + """Create appropriate PackageMeta subclass based on package_type. + + Args: + package_config: Package configuration + + Returns: + PackageMeta subclass instance (HomebrewMeta, SnapMeta, or PackageMeta) + + Raises: + ValueError: If package_type is None + """ + if package_config.package_type == PackageType.HOMEBREW: + return HomebrewMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + elif package_config.package_type == PackageType.SNAP: + return SnapMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + elif package_config.package_type is not None: + return PackageMeta( + repo=package_config.repo, + ref=package_config.ref, + package_type=package_config.package_type, + publish_internal_release=package_config.publish_internal_release, + ) + else: + raise ValueError( + f"package_type must be a PackageType, got {type(package_config.package_type).__name__}" + ) + @classmethod def from_config(cls, config: Config) -> "ReleaseState": """Build ReleaseState from config with default values.""" @@ -209,33 +276,10 @@ def from_config(cls, config: Config) -> "ReleaseState": ) # Initialize package metadata - create appropriate subclass based on package_type - package_meta: Union[HomebrewMeta, SnapMeta, PackageMeta] - if package_config.package_type == PackageType.HOMEBREW: - package_meta = HomebrewMeta( - repo=package_config.repo, - ref=package_config.ref, - package_type=package_config.package_type, - publish_internal_release=package_config.publish_internal_release, - ) - elif package_config.package_type == PackageType.SNAP: - package_meta = SnapMeta( - repo=package_config.repo, - ref=package_config.ref, - package_type=package_config.package_type, - publish_internal_release=package_config.publish_internal_release, - ) - elif package_config.package_type is not None: - package_meta = PackageMeta( - repo=package_config.repo, - ref=package_config.ref, - package_type=package_config.package_type, - publish_internal_release=package_config.publish_internal_release, - ) - else: - raise ValueError( - f"Package '{package_name}': package_type must be a PackageType, " - f"got {type(package_config.package_type).__name__}" - ) + try: + package_meta = cls._create_package_meta_from_config(package_config) + except ValueError as e: + raise ValueError(f"Package '{package_name}': {e}") from e # Initialize build workflow build_workflow = Workflow( diff --git a/src/redis_release/bht/tree.py b/src/redis_release/bht/tree.py index df3ff5a..20b260f 100644 --- a/src/redis_release/bht/tree.py +++ b/src/redis_release/bht/tree.py @@ -34,19 +34,16 @@ RestartWorkflowGuarded, ) from .ppas import ( - create_attach_release_handle_ppa, - create_build_workflow_inputs_ppa, - create_detect_release_type_ppa, create_download_artifacts_ppa, create_extract_artifact_result_ppa, create_find_workflow_by_uuid_ppa, create_identify_target_ref_ppa, - create_publish_workflow_inputs_ppa, create_trigger_workflow_ppa, create_workflow_completion_ppa, create_workflow_success_ppa, ) from .state import Package, PackageMeta, ReleaseMeta, ReleaseState, Workflow +from .tree_factory import get_factory logger = logging.getLogger(__name__) @@ -195,7 +192,9 @@ def create_root_node( logger.info(f"Skipping package {package_name} as it's not in only_packages") continue root.add_child( - create_package_release_tree_branch( + get_factory( + package.meta.package_type + ).create_package_release_goal_tree_branch( package, state.meta, default_state.packages[package_name], @@ -206,244 +205,6 @@ def create_root_node( return root -def create_package_release_tree_branch( - package: Package, - release_meta: ReleaseMeta, - default_package: Package, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - build = create_build_workflow_tree_branch( - package, - release_meta, - default_package, - github_client, - package_name, - ) - build.name = f"Build {package_name}" - publish = create_publish_workflow_tree_branch( - package.build, - package.publish, - package.meta, - release_meta, - default_package.publish, - github_client, - package_name, - ) - reset_package_state = ResetPackageStateGuarded( - "", - package, - default_package, - log_prefix=package_name, - ) - publish.name = f"Publish {package_name}" - package_release = Sequence( - f"Package Release {package_name}", - memory=False, - children=[reset_package_state, build, publish], - ) - return package_release - - -def create_build_workflow_tree_branch( - package: Package, - release_meta: ReleaseMeta, - default_package: Package, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - - build_workflow_args = create_build_workflow_inputs_ppa( - package.build, package.meta, release_meta, log_prefix=f"{package_name}.build" - ) - build_workflow = create_workflow_with_result_tree_branch( - "release_handle", - package.build, - package.meta, - release_meta, - github_client, - f"{package_name}.build", - trigger_preconditions=[build_workflow_args], - ) - assert isinstance(build_workflow, Selector) - - reset_package_state = RestartPackageGuarded( - "BuildRestartCondition", - package, - package.build, - default_package, - log_prefix=f"{package_name}.build", - ) - build_workflow.add_child(reset_package_state) - - return build_workflow - - -def create_publish_workflow_tree_branch( - build_workflow: Workflow, - publish_workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - default_publish_workflow: Workflow, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - attach_release_handle = create_attach_release_handle_ppa( - build_workflow, publish_workflow, log_prefix=f"{package_name}.publish" - ) - publish_workflow_args = create_publish_workflow_inputs_ppa( - publish_workflow, - package_meta, - release_meta, - log_prefix=f"{package_name}.publish", - ) - workflow_result = create_workflow_with_result_tree_branch( - "release_info", - publish_workflow, - package_meta, - release_meta, - github_client, - f"{package_name}.publish", - trigger_preconditions=[publish_workflow_args, attach_release_handle], - ) - not_need_to_publish = Inverter( - "Not", - NeedToPublishRelease( - "Need To Publish?", - package_meta, - release_meta, - log_prefix=f"{package_name}.publish", - ), - ) - reset_publish_workflow_state = RestartWorkflowGuarded( - "PublishRestartCondition", - publish_workflow, - package_meta, - default_publish_workflow, - log_prefix=f"{package_name}.publish", - ) - return Selector( - "Publish", - memory=False, - children=[not_need_to_publish, workflow_result, reset_publish_workflow_state], - ) - - -def create_workflow_with_result_tree_branch( - artifact_name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - github_client: GitHubClientAsync, - package_name: str, - trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, -) -> Union[Selector, Sequence]: - """ - Creates a workflow process that succedes when the workflow - is successful and a result artifact is extracted and json decoded. - - Args: - trigger_preconditions: List of preconditions to add to the workflow trigger - """ - workflow_result = create_extract_result_tree_branch( - artifact_name, - workflow, - package_meta, - github_client, - package_name, - ) - workflow_complete = create_workflow_complete_tree_branch( - workflow, - package_meta, - release_meta, - github_client, - package_name, - trigger_preconditions, - ) - - latch_chains(workflow_result, workflow_complete) - - return workflow_result - - -def create_workflow_complete_tree_branch( - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - github_client: GitHubClientAsync, - log_prefix: str, - trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, -) -> Union[Selector, Sequence]: - """ - - Args: - trigger_preconditions: List of preconditions to add to the workflow trigger - """ - workflow_complete = create_workflow_completion_ppa( - workflow, - package_meta, - github_client, - log_prefix, - ) - find_workflow_by_uud = create_find_workflow_by_uuid_ppa( - workflow, - package_meta, - github_client, - log_prefix, - ) - trigger_workflow = create_trigger_workflow_ppa( - workflow, - package_meta, - release_meta, - github_client, - log_prefix, - ) - if trigger_preconditions: - latch_chains(trigger_workflow, *trigger_preconditions) - identify_target_ref = create_identify_target_ref_ppa( - package_meta, - release_meta, - github_client, - log_prefix, - ) - detect_release_type = create_detect_release_type_ppa( - release_meta, - log_prefix, - ) - latch_chains( - workflow_complete, - find_workflow_by_uud, - trigger_workflow, - identify_target_ref, - detect_release_type, - ) - return workflow_complete - - -def create_extract_result_tree_branch( - artifact_name: str, - workflow: Workflow, - package_meta: PackageMeta, - github_client: GitHubClientAsync, - log_prefix: str, -) -> Union[Selector, Sequence]: - extract_artifact_result = create_extract_artifact_result_ppa( - artifact_name, - workflow, - package_meta, - github_client, - log_prefix, - ) - download_artifacts = create_download_artifacts_ppa( - workflow, - package_meta, - github_client, - log_prefix, - ) - latch_chains(extract_artifact_result, download_artifacts) - return extract_artifact_result - - class TreeInspector: """Inspector for creating and inspecting behavior tree branches and PPAs.""" diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py index 7bde9b9..5b61967 100644 --- a/src/redis_release/bht/tree_factory.py +++ b/src/redis_release/bht/tree_factory.py @@ -1,43 +1,73 @@ """ -Package-specific tree factories. - -This module provides factories for creating package-type-specific behaviors -and tree branches. Each package type gets its own factory class that knows -how to create all the specialized behaviors and tree structures for that type. +Package-specific tree factories and factory functions. """ -from abc import ABC, abstractmethod -from typing import Dict, Optional +import logging +from abc import ABC +from typing import Dict, List, Optional, Union, cast from py_trees.behaviour import Behaviour +from py_trees.behaviours import Failure as AlwaysFailure +from py_trees.composites import Selector, Sequence +from py_trees.decorators import Inverter +from ..github_client_async import GitHubClientAsync from ..models import PackageType -from .behaviours import GenericWorkflowInputs -from .state import PackageMeta, ReleaseMeta, Workflow - - -class TreeFactory(ABC): - """Abstract base class for package-specific tree factories. - - Subclasses can override specific methods to customize behavior for - different package types. Methods not overridden will use the default - implementation from GenericFactory. - """ - - @abstractmethod - def create_build_workflow_inputs( +from .backchain import create_PPA, latch_chains +from .behaviours import ( + ClassifyHomebrewVersion, + DetectHomebrewChannel, + GenericWorkflowInputs, + NeedToPublishRelease, +) +from .composites import ( + ResetPackageStateGuarded, + RestartPackageGuarded, + RestartWorkflowGuarded, +) +from .ppas import ( + create_attach_release_handle_ppa, + create_detect_release_type_ppa, + create_download_artifacts_ppa, + create_extract_artifact_result_ppa, + create_find_workflow_by_uuid_ppa, + create_identify_target_ref_ppa, + create_trigger_workflow_ppa, + create_workflow_completion_ppa, +) +from .state import ( + HomebrewMeta, + Package, + PackageMeta, + ReleaseMeta, + ReleaseState, + Workflow, +) + +logger = logging.getLogger(__name__) + + +class GenericFactory(ABC): + """Default factory for packages without specific customizations.""" + + def create_package_release_goal_tree_branch( self, - name: str, - workflow: Workflow, - package_meta: PackageMeta, + package: Package, release_meta: ReleaseMeta, - log_prefix: str, - ) -> Behaviour: - """Create behavior for preparing build workflow inputs.""" - pass + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + package_release = create_package_release_tree_branch( + package, release_meta, default_package, github_client, package_name + ) + return Selector( + f"Package Release {package_name} Goal", + memory=False, + children=[AlwaysFailure("Yes"), package_release], + ) - @abstractmethod - def create_publish_workflow_inputs( + def create_build_workflow_inputs( self, name: str, workflow: Workflow, @@ -45,29 +75,11 @@ def create_publish_workflow_inputs( release_meta: ReleaseMeta, log_prefix: str, ) -> Behaviour: - """Create behavior for preparing publish workflow inputs.""" - pass - - # Add more methods as you need different behaviors or tree branches - # @abstractmethod - # def create_artifact_handler(self, ...) -> Behaviour: - # """Create behavior for handling package artifacts.""" - # pass - # - # @abstractmethod - # def create_validation_branch(self, ...) -> Sequence: - # """Create a tree branch for package validation.""" - # pass - - -class GenericFactory(TreeFactory): - """Default factory for packages without specific customizations. - - This provides the base implementation that other factories can inherit - and selectively override. - """ + return GenericWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) - def create_build_workflow_inputs( + def create_publish_workflow_inputs( self, name: str, workflow: Workflow, @@ -79,17 +91,59 @@ def create_build_workflow_inputs( name, workflow, package_meta, release_meta, log_prefix=log_prefix ) - def create_publish_workflow_inputs( + def create_workflow_complete_tree_branch( self, - name: str, workflow: Workflow, package_meta: PackageMeta, release_meta: ReleaseMeta, + github_client: GitHubClientAsync, log_prefix: str, - ) -> Behaviour: - return GenericWorkflowInputs( - name, workflow, package_meta, release_meta, log_prefix=log_prefix + trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, + ) -> Union[Selector, Sequence]: + """ + + Args: + trigger_preconditions: List of preconditions to add to the workflow trigger + """ + workflow_complete = create_workflow_completion_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + find_workflow_by_uud = create_find_workflow_by_uuid_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + trigger_workflow = create_trigger_workflow_ppa( + workflow, + package_meta, + release_meta, + github_client, + log_prefix, ) + if trigger_preconditions: + latch_chains(trigger_workflow, *trigger_preconditions) + identify_target_ref = create_identify_target_ref_ppa( + package_meta, + release_meta, + github_client, + log_prefix, + ) + detect_release_type = create_detect_release_type_ppa( + release_meta, + log_prefix, + ) + latch_chains( + workflow_complete, + find_workflow_by_uud, + trigger_workflow, + identify_target_ref, + detect_release_type, + ) + return workflow_complete class DebianFactory(GenericFactory): @@ -129,25 +183,105 @@ def create_publish_workflow_inputs( class DockerFactory(GenericFactory): - """Factory for Docker packages. + """Factory for Docker packages.""" - Currently uses generic implementations. Override methods as needed - when Docker-specific behavior is required. - """ + pass - pass # Inherits all methods from GenericFactory + +class HomebrewFactory(GenericFactory): + def create_package_release_goal_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + logger.error("Creating Homebrew package release goal tree branch") + package_release = create_package_release_tree_branch( + package, release_meta, default_package, github_client, package_name + ) + release_goal = Selector( + f"Package Release {package_name} Goal", + memory=False, + children=[AlwaysFailure("Yes"), package_release], + ) + return Sequence( + f"Release Validation {package_name}", + memory=False, + children=[ + DetectHomebrewChannel( + "Detect Homebrew Channel", + cast(HomebrewMeta, package.meta), + release_meta, + log_prefix=package_name, + ), + ClassifyHomebrewVersion( + "Classify Homebrew Version", + cast(HomebrewMeta, package.meta), + release_meta, + github_client, + log_prefix=package_name, + ), + release_goal, + ], + ) + + def create_workflow_complete_tree_branch( + self, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str, + trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, + ) -> Union[Selector, Sequence]: + """ + + Args: + trigger_preconditions: List of preconditions to add to the workflow trigger + """ + workflow_complete = create_workflow_completion_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + find_workflow_by_uud = create_find_workflow_by_uuid_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + trigger_workflow = create_trigger_workflow_ppa( + workflow, + package_meta, + release_meta, + github_client, + log_prefix, + ) + if trigger_preconditions: + latch_chains(trigger_workflow, *trigger_preconditions) + + latch_chains( + workflow_complete, + find_workflow_by_uud, + trigger_workflow, + ) + return workflow_complete # Factory registry -_FACTORIES: Dict[PackageType, TreeFactory] = { +_FACTORIES: Dict[PackageType, GenericFactory] = { PackageType.DEBIAN: DebianFactory(), PackageType.DOCKER: DockerFactory(), + PackageType.HOMEBREW: HomebrewFactory(), } _DEFAULT_FACTORY = GenericFactory() -def get_factory(package_type: Optional[PackageType]) -> TreeFactory: +def get_factory(package_type: Optional[PackageType]) -> GenericFactory: """Get the factory for a given package type. Args: @@ -173,13 +307,199 @@ def create_build_workflow_inputs_behaviour( ) -def create_publish_workflow_inputs_behaviour( - name: str, +def create_package_release_tree_branch( + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, +) -> Union[Selector, Sequence]: + build = create_build_workflow_tree_branch( + package, + release_meta, + default_package, + github_client, + package_name, + ) + build.name = f"Build {package_name}" + publish = create_publish_workflow_tree_branch( + package.build, + package.publish, + package.meta, + release_meta, + default_package.publish, + github_client, + package_name, + ) + reset_package_state = ResetPackageStateGuarded( + "", + package, + default_package, + log_prefix=package_name, + ) + publish.name = f"Publish {package_name}" + package_release = Sequence( + f"Package Release {package_name}", + memory=False, + children=[reset_package_state, build, publish], + ) + return package_release + + +def create_build_workflow_tree_branch( + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, +) -> Union[Selector, Sequence]: + + build_workflow_args = create_PPA( + "Set Build Workflow Inputs", + get_factory(package.meta.package_type).create_build_workflow_inputs( + "Set Build Workflow Inputs", + package.build, + package.meta, + release_meta, + log_prefix=f"{package_name}.build", + ), + ) + + build_workflow = create_workflow_with_result_tree_branch( + "release_handle", + package.build, + package.meta, + release_meta, + github_client, + f"{package_name}.build", + trigger_preconditions=[build_workflow_args], + ) + assert isinstance(build_workflow, Selector) + + reset_package_state = RestartPackageGuarded( + "BuildRestartCondition", + package, + package.build, + default_package, + log_prefix=f"{package_name}.build", + ) + build_workflow.add_child(reset_package_state) + + return build_workflow + + +def create_publish_workflow_tree_branch( + build_workflow: Workflow, + publish_workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + default_publish_workflow: Workflow, + github_client: GitHubClientAsync, + package_name: str, +) -> Union[Selector, Sequence]: + attach_release_handle = create_attach_release_handle_ppa( + build_workflow, publish_workflow, log_prefix=f"{package_name}.publish" + ) + publish_workflow_args = create_PPA( + "Set Publish Workflow Inputs", + get_factory(package_meta.package_type).create_publish_workflow_inputs( + "Set Publish Workflow Inputs", + publish_workflow, + package_meta, + release_meta, + log_prefix=f"{package_name}.publish", + ), + ) + workflow_result = create_workflow_with_result_tree_branch( + "release_info", + publish_workflow, + package_meta, + release_meta, + github_client, + f"{package_name}.publish", + trigger_preconditions=[publish_workflow_args, attach_release_handle], + ) + not_need_to_publish = Inverter( + "Not", + NeedToPublishRelease( + "Need To Publish?", + package_meta, + release_meta, + log_prefix=f"{package_name}.publish", + ), + ) + reset_publish_workflow_state = RestartWorkflowGuarded( + "PublishRestartCondition", + publish_workflow, + package_meta, + default_publish_workflow, + log_prefix=f"{package_name}.publish", + ) + return Selector( + "Publish", + memory=False, + children=[not_need_to_publish, workflow_result, reset_publish_workflow_state], + ) + + +def create_workflow_with_result_tree_branch( + artifact_name: str, workflow: Workflow, package_meta: PackageMeta, release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + package_name: str, + trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, +) -> Union[Selector, Sequence]: + """ + Creates a workflow process that succedes when the workflow + is successful and a result artifact is extracted and json decoded. + + Args: + trigger_preconditions: List of preconditions to add to the workflow trigger + """ + workflow_result = create_extract_result_tree_branch( + artifact_name, + workflow, + package_meta, + github_client, + package_name, + ) + workflow_complete = get_factory( + package_meta.package_type + ).create_workflow_complete_tree_branch( + workflow, + package_meta, + release_meta, + github_client, + package_name, + trigger_preconditions, + ) + + latch_chains(workflow_result, workflow_complete) + + return workflow_result + + +def create_extract_result_tree_branch( + artifact_name: str, + workflow: Workflow, + package_meta: PackageMeta, + github_client: GitHubClientAsync, log_prefix: str, -) -> Behaviour: - return get_factory(package_meta.package_type).create_publish_workflow_inputs( - name, workflow, package_meta, release_meta, log_prefix +) -> Union[Selector, Sequence]: + extract_artifact_result = create_extract_artifact_result_ppa( + artifact_name, + workflow, + package_meta, + github_client, + log_prefix, + ) + download_artifacts = create_download_artifacts_ppa( + workflow, + package_meta, + github_client, + log_prefix, ) + latch_chains(extract_artifact_result, download_artifacts) + return extract_artifact_result diff --git a/src/redis_release/models.py b/src/redis_release/models.py index 337313a..ce567e6 100644 --- a/src/redis_release/models.py +++ b/src/redis_release/models.py @@ -1,5 +1,6 @@ """Data models for Redis release automation.""" +import functools import re from enum import Enum from typing import List, Optional @@ -86,6 +87,7 @@ class WorkflowRun(BaseModel): conclusion: Optional[WorkflowConclusion] = None +@functools.total_ordering class RedisVersion(BaseModel): """Represents a parsed Redis version. @@ -138,6 +140,21 @@ def is_eol(self) -> bool: """Check if this version is end-of-life.""" return self.suffix.lower().endswith("-eol") + @property + def is_rc(self) -> bool: + """Check if this version is a release candidate.""" + return self.suffix.lower().startswith("rc") + + @property + def is_ga(self) -> bool: + """Check if this version is a general availability (GA) release.""" + return not self.is_milestone + + @property + def is_internal(self) -> bool: + """Check if this version is an internal release.""" + return self.suffix.lower().startswith("int") + @property def mainline_version(self) -> str: """Get the mainline version string (major.minor).""" @@ -183,6 +200,39 @@ def __lt__(self, other: "RedisVersion") -> bool: return self.suffix < other.suffix + def __le__(self, other: "RedisVersion") -> bool: + """Less than or equal comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return self < other or self == other + + def __gt__(self, other: "RedisVersion") -> bool: + """Greater than comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return not self <= other + + def __ge__(self, other: "RedisVersion") -> bool: + """Greater than or equal comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return not self < other + + def __eq__(self, other: object) -> bool: + """Equality comparison.""" + if not isinstance(other, RedisVersion): + return NotImplemented + return ( + self.major == other.major + and self.minor == other.minor + and (self.patch or 0) == (other.patch or 0) + and self.suffix == other.suffix + ) + + def __hash__(self) -> int: + """Hash for use in sets and dicts.""" + return hash((self.major, self.minor, self.patch or 0, self.suffix)) + class ReleaseArgs(BaseModel): """Arguments for release execution.""" diff --git a/src/redis_release/state_manager.py b/src/redis_release/state_manager.py index 7ac46e0..fe2abe7 100644 --- a/src/redis_release/state_manager.py +++ b/src/redis_release/state_manager.py @@ -264,12 +264,13 @@ def load(self) -> Optional[ReleaseState]: def _reset_ephemeral_fields(self, state: ReleaseState) -> None: """Reset ephemeral fields to defaults (except log_once_flags which are always reset).""" + # Reset release meta ephemeral state.meta.ephemeral = state.meta.ephemeral.__class__() # Reset package ephemeral fields for package in state.packages.values(): - package.meta.ephemeral = package.meta.ephemeral.__class__() + package.meta.ephemeral = package.meta.ephemeral.__class__() # type: ignore package.build.ephemeral = package.build.ephemeral.__class__() package.publish.ephemeral = package.publish.ephemeral.__class__() From 4e24ee28654e061bea2815b04e9fade6ef735608 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 10:56:49 +0200 Subject: [PATCH 04/13] Downalod files from GitHub --- src/redis_release/github_client_async.py | 64 ++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/redis_release/github_client_async.py b/src/redis_release/github_client_async.py index 47508a9..55e39b6 100644 --- a/src/redis_release/github_client_async.py +++ b/src/redis_release/github_client_async.py @@ -1,5 +1,6 @@ """Async GitHub API client for workflow operations.""" +import base64 import io import json import logging @@ -603,6 +604,69 @@ def _extract_uuid(self, text: str) -> Optional[str]: uuid_match = re.search(uuid_pattern, text, re.IGNORECASE) return uuid_match.group() if uuid_match else None + async def download_file( + self, repo: str, file_path: str, ref: str = "main" + ) -> Optional[str]: + """Download a specific file from a repository at a specific branch/ref. + + Args: + repo: Repository name (e.g., "redis/redis") + file_path: Path to the file in the repository (e.g., "config.yaml", "src/main.py") + ref: Git reference (branch, tag, or commit SHA) to download from (default: "main") + + Returns: + File content as a string, or None if the file is not found or an error occurs + """ + url = f"https://api.github.com/repos/{repo}/contents/{file_path}" + headers = { + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + } + params = {"ref": ref} + + try: + logger.debug( + f"[blue]Downloading file[/blue] {file_path} from {repo} at ref {ref}" + ) + + data = await self.github_request( + url=url, + headers=headers, + method="GET", + params=params, + timeout=30, + error_context=f"download file {file_path}", + ) + + # GitHub API returns file content base64-encoded + if "content" in data and data.get("encoding") == "base64": + content = base64.b64decode(data["content"]).decode("utf-8") + logger.debug( + f"[green]Successfully downloaded {file_path}[/green] ({len(content)} bytes)" + ) + return content + else: + logger.error( + f"[red]Unexpected response format for file {file_path}[/red]" + ) + return None + + except aiohttp.ClientResponseError as e: + if e.status == 404: + logger.warning( + f"[yellow]File {file_path} not found in {repo} at ref {ref}[/yellow]" + ) + else: + logger.error(f"[red]Failed to download file {file_path}: {e}[/red]") + return None + except aiohttp.ClientError as e: + logger.error(f"[red]Failed to download file {file_path}: {e}[/red]") + return None + except Exception as e: + logger.error(f"[red]Error downloading file {file_path}: {e}[/red]") + return None + async def list_remote_branches( self, repo: str, pattern: Optional[str] = None ) -> List[str]: From 4f14c3d6cc6a6c0197a7cd0ca583b6b55fce66d7 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 12:30:12 +0200 Subject: [PATCH 05/13] Move functions to factory method --- src/redis_release/bht/tree.py | 30 +- src/redis_release/bht/tree_factory.py | 414 +++++++++++++------------- src/redis_release/cli.py | 8 +- 3 files changed, 233 insertions(+), 219 deletions(-) diff --git a/src/redis_release/bht/tree.py b/src/redis_release/bht/tree.py index 20b260f..9feb3d4 100644 --- a/src/redis_release/bht/tree.py +++ b/src/redis_release/bht/tree.py @@ -21,7 +21,7 @@ from ..config import Config from ..github_client_async import GitHubClientAsync -from ..models import ReleaseArgs +from ..models import PackageType, ReleaseArgs from ..state_display import print_state_table from ..state_manager import S3StateStorage, StateManager, StateStorage from ..state_slack import SlackStatePrinter, init_slack_printer @@ -221,17 +221,23 @@ class TreeInspector: "workflow_with_result_branch", "publish_workflow_branch", "build_workflow_branch", + "package_release_branch", + "package_release_goal_branch", "demo_sequence", "demo_selector", ] - def __init__(self, release_tag: str): + def __init__(self, release_tag: str, package_type: Optional[str] = None): """Initialize TreeInspector. Args: release_tag: Release tag for creating mock ReleaseMeta """ self.release_tag = release_tag + if package_type: + self.package_type = PackageType(package_type) + else: + self.package_type = PackageType.DOCKER def get_names(self) -> List[str]: """Get list of available branch/PPA names. @@ -297,15 +303,17 @@ def create_by_name(self, name: str) -> Union[Selector, Sequence, Behaviour]: "test-artifact", workflow, package_meta, github_client, log_prefix ) elif name == "workflow_complete_branch": - return create_workflow_complete_tree_branch( + return get_factory(self.package_type).create_workflow_complete_tree_branch( workflow, package_meta, release_meta, github_client, "" ) elif name == "workflow_with_result_branch": - return create_workflow_with_result_tree_branch( + return get_factory( + self.package_type + ).create_workflow_with_result_tree_branch( "artifact", workflow, package_meta, release_meta, github_client, "" ) elif name == "publish_workflow_branch": - return create_publish_workflow_tree_branch( + return get_factory(self.package_type).create_publish_workflow_tree_branch( workflow, workflow, package_meta, @@ -315,7 +323,17 @@ def create_by_name(self, name: str) -> Union[Selector, Sequence, Behaviour]: "", ) elif name == "build_workflow_branch": - return create_build_workflow_tree_branch( + return get_factory(self.package_type).create_build_workflow_tree_branch( + package, release_meta, package, github_client, "" + ) + elif name == "package_release_branch": + return get_factory(self.package_type).create_package_release_tree_branch( + package, release_meta, package, github_client, "" + ) + elif name == "package_release_goal_branch": + return get_factory( + self.package_type + ).create_package_release_goal_tree_branch( package, release_meta, package, github_client, "" ) elif name == "demo_sequence": diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py index 5b61967..16705aa 100644 --- a/src/redis_release/bht/tree_factory.py +++ b/src/redis_release/bht/tree_factory.py @@ -58,7 +58,7 @@ def create_package_release_goal_tree_branch( github_client: GitHubClientAsync, package_name: str, ) -> Union[Selector, Sequence]: - package_release = create_package_release_tree_branch( + package_release = self.create_package_release_tree_branch( package, release_meta, default_package, github_client, package_name ) return Selector( @@ -145,6 +145,206 @@ def create_workflow_complete_tree_branch( ) return workflow_complete + def create_package_release_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + build = self.create_build_workflow_tree_branch( + package, + release_meta, + default_package, + github_client, + package_name, + ) + build.name = f"Build {package_name}" + publish = self.create_publish_workflow_tree_branch( + package.build, + package.publish, + package.meta, + release_meta, + default_package.publish, + github_client, + package_name, + ) + reset_package_state = ResetPackageStateGuarded( + "", + package, + default_package, + log_prefix=package_name, + ) + publish.name = f"Publish {package_name}" + package_release = Sequence( + f"Package Release {package_name}", + memory=False, + children=[reset_package_state, build, publish], + ) + return package_release + + def create_build_workflow_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + + build_workflow_args = create_PPA( + "Set Build Workflow Inputs", + self.create_build_workflow_inputs( + "Set Build Workflow Inputs", + package.build, + package.meta, + release_meta, + log_prefix=f"{package_name}.build", + ), + ) + + build_workflow = self.create_workflow_with_result_tree_branch( + "release_handle", + package.build, + package.meta, + release_meta, + github_client, + f"{package_name}.build", + trigger_preconditions=[build_workflow_args], + ) + assert isinstance(build_workflow, Selector) + + reset_package_state = RestartPackageGuarded( + "BuildRestartCondition", + package, + package.build, + default_package, + log_prefix=f"{package_name}.build", + ) + build_workflow.add_child(reset_package_state) + + return build_workflow + + def create_publish_workflow_tree_branch( + self, + build_workflow: Workflow, + publish_workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + default_publish_workflow: Workflow, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + attach_release_handle = create_attach_release_handle_ppa( + build_workflow, publish_workflow, log_prefix=f"{package_name}.publish" + ) + publish_workflow_args = create_PPA( + "Set Publish Workflow Inputs", + self.create_publish_workflow_inputs( + "Set Publish Workflow Inputs", + publish_workflow, + package_meta, + release_meta, + log_prefix=f"{package_name}.publish", + ), + ) + workflow_result = self.create_workflow_with_result_tree_branch( + "release_info", + publish_workflow, + package_meta, + release_meta, + github_client, + f"{package_name}.publish", + trigger_preconditions=[publish_workflow_args, attach_release_handle], + ) + not_need_to_publish = Inverter( + "Not", + NeedToPublishRelease( + "Need To Publish?", + package_meta, + release_meta, + log_prefix=f"{package_name}.publish", + ), + ) + reset_publish_workflow_state = RestartWorkflowGuarded( + "PublishRestartCondition", + publish_workflow, + package_meta, + default_publish_workflow, + log_prefix=f"{package_name}.publish", + ) + return Selector( + "Publish", + memory=False, + children=[ + not_need_to_publish, + workflow_result, + reset_publish_workflow_state, + ], + ) + + def create_workflow_with_result_tree_branch( + self, + artifact_name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + package_name: str, + trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, + ) -> Union[Selector, Sequence]: + """ + Creates a workflow process that succedes when the workflow + is successful and a result artifact is extracted and json decoded. + + Args: + trigger_preconditions: List of preconditions to add to the workflow trigger + """ + workflow_result = self.create_extract_result_tree_branch( + artifact_name, + workflow, + package_meta, + github_client, + package_name, + ) + workflow_complete = self.create_workflow_complete_tree_branch( + workflow, + package_meta, + release_meta, + github_client, + package_name, + trigger_preconditions, + ) + + latch_chains(workflow_result, workflow_complete) + + return workflow_result + + def create_extract_result_tree_branch( + self, + artifact_name: str, + workflow: Workflow, + package_meta: PackageMeta, + github_client: GitHubClientAsync, + log_prefix: str, + ) -> Union[Selector, Sequence]: + extract_artifact_result = create_extract_artifact_result_ppa( + artifact_name, + workflow, + package_meta, + github_client, + log_prefix, + ) + download_artifacts = create_download_artifacts_ppa( + workflow, + package_meta, + github_client, + log_prefix, + ) + latch_chains(extract_artifact_result, download_artifacts) + return extract_artifact_result + class DebianFactory(GenericFactory): """Factory for Debian packages. @@ -198,7 +398,7 @@ def create_package_release_goal_tree_branch( package_name: str, ) -> Union[Selector, Sequence]: logger.error("Creating Homebrew package release goal tree branch") - package_release = create_package_release_tree_branch( + package_release = self.create_package_release_tree_branch( package, release_meta, default_package, github_client, package_name ) release_goal = Selector( @@ -293,213 +493,3 @@ def get_factory(package_type: Optional[PackageType]) -> GenericFactory: if package_type is None: return _DEFAULT_FACTORY return _FACTORIES.get(package_type, _DEFAULT_FACTORY) - - -def create_build_workflow_inputs_behaviour( - name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, -) -> Behaviour: - return get_factory(package_meta.package_type).create_build_workflow_inputs( - name, workflow, package_meta, release_meta, log_prefix - ) - - -def create_package_release_tree_branch( - package: Package, - release_meta: ReleaseMeta, - default_package: Package, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - build = create_build_workflow_tree_branch( - package, - release_meta, - default_package, - github_client, - package_name, - ) - build.name = f"Build {package_name}" - publish = create_publish_workflow_tree_branch( - package.build, - package.publish, - package.meta, - release_meta, - default_package.publish, - github_client, - package_name, - ) - reset_package_state = ResetPackageStateGuarded( - "", - package, - default_package, - log_prefix=package_name, - ) - publish.name = f"Publish {package_name}" - package_release = Sequence( - f"Package Release {package_name}", - memory=False, - children=[reset_package_state, build, publish], - ) - return package_release - - -def create_build_workflow_tree_branch( - package: Package, - release_meta: ReleaseMeta, - default_package: Package, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - - build_workflow_args = create_PPA( - "Set Build Workflow Inputs", - get_factory(package.meta.package_type).create_build_workflow_inputs( - "Set Build Workflow Inputs", - package.build, - package.meta, - release_meta, - log_prefix=f"{package_name}.build", - ), - ) - - build_workflow = create_workflow_with_result_tree_branch( - "release_handle", - package.build, - package.meta, - release_meta, - github_client, - f"{package_name}.build", - trigger_preconditions=[build_workflow_args], - ) - assert isinstance(build_workflow, Selector) - - reset_package_state = RestartPackageGuarded( - "BuildRestartCondition", - package, - package.build, - default_package, - log_prefix=f"{package_name}.build", - ) - build_workflow.add_child(reset_package_state) - - return build_workflow - - -def create_publish_workflow_tree_branch( - build_workflow: Workflow, - publish_workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - default_publish_workflow: Workflow, - github_client: GitHubClientAsync, - package_name: str, -) -> Union[Selector, Sequence]: - attach_release_handle = create_attach_release_handle_ppa( - build_workflow, publish_workflow, log_prefix=f"{package_name}.publish" - ) - publish_workflow_args = create_PPA( - "Set Publish Workflow Inputs", - get_factory(package_meta.package_type).create_publish_workflow_inputs( - "Set Publish Workflow Inputs", - publish_workflow, - package_meta, - release_meta, - log_prefix=f"{package_name}.publish", - ), - ) - workflow_result = create_workflow_with_result_tree_branch( - "release_info", - publish_workflow, - package_meta, - release_meta, - github_client, - f"{package_name}.publish", - trigger_preconditions=[publish_workflow_args, attach_release_handle], - ) - not_need_to_publish = Inverter( - "Not", - NeedToPublishRelease( - "Need To Publish?", - package_meta, - release_meta, - log_prefix=f"{package_name}.publish", - ), - ) - reset_publish_workflow_state = RestartWorkflowGuarded( - "PublishRestartCondition", - publish_workflow, - package_meta, - default_publish_workflow, - log_prefix=f"{package_name}.publish", - ) - return Selector( - "Publish", - memory=False, - children=[not_need_to_publish, workflow_result, reset_publish_workflow_state], - ) - - -def create_workflow_with_result_tree_branch( - artifact_name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - github_client: GitHubClientAsync, - package_name: str, - trigger_preconditions: Optional[List[Union[Sequence, Selector]]] = None, -) -> Union[Selector, Sequence]: - """ - Creates a workflow process that succedes when the workflow - is successful and a result artifact is extracted and json decoded. - - Args: - trigger_preconditions: List of preconditions to add to the workflow trigger - """ - workflow_result = create_extract_result_tree_branch( - artifact_name, - workflow, - package_meta, - github_client, - package_name, - ) - workflow_complete = get_factory( - package_meta.package_type - ).create_workflow_complete_tree_branch( - workflow, - package_meta, - release_meta, - github_client, - package_name, - trigger_preconditions, - ) - - latch_chains(workflow_result, workflow_complete) - - return workflow_result - - -def create_extract_result_tree_branch( - artifact_name: str, - workflow: Workflow, - package_meta: PackageMeta, - github_client: GitHubClientAsync, - log_prefix: str, -) -> Union[Selector, Sequence]: - extract_artifact_result = create_extract_artifact_result_ppa( - artifact_name, - workflow, - package_meta, - github_client, - log_prefix, - ) - download_artifacts = create_download_artifacts_ppa( - workflow, - package_meta, - github_client, - log_prefix, - ) - latch_chains(extract_artifact_result, download_artifacts) - return extract_artifact_result diff --git a/src/redis_release/cli.py b/src/redis_release/cli.py index aea49ef..469405a 100644 --- a/src/redis_release/cli.py +++ b/src/redis_release/cli.py @@ -33,6 +33,12 @@ @app.command() def release_print( release_tag: str = typer.Argument(..., help="Release tag (e.g., 8.4-m01-int1)"), + package_type: Optional[str] = typer.Option( + None, + "--package-type", + "-p", + help="Package type to use for creating the tree (default: docker)", + ), config_file: Optional[str] = typer.Option( None, "--config", "-c", help="Path to config file (default: config.yaml)" ), @@ -62,7 +68,7 @@ def release_print( if name: # Create TreeInspector and render the requested branch - inspector = TreeInspector(release_tag=release_tag) + inspector = TreeInspector(release_tag=release_tag, package_type=package_type) try: branch = inspector.create_by_name(name) From 473b84c2501a47d73f7df122dc519feaeac4d4ee Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 12:31:00 +0200 Subject: [PATCH 06/13] Fix redis version comparison --- src/redis_release/models.py | 47 +++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/src/redis_release/models.py b/src/redis_release/models.py index ce567e6..f8ee021 100644 --- a/src/redis_release/models.py +++ b/src/redis_release/models.py @@ -143,7 +143,7 @@ def is_eol(self) -> bool: @property def is_rc(self) -> bool: """Check if this version is a release candidate.""" - return self.suffix.lower().startswith("rc") + return self.suffix.lower().startswith("-rc") @property def is_ga(self) -> bool: @@ -153,7 +153,7 @@ def is_ga(self) -> bool: @property def is_internal(self) -> bool: """Check if this version is an internal release.""" - return self.suffix.lower().startswith("int") + return bool(re.search(r"-int\d*$", self.suffix.lower())) @property def mainline_version(self) -> str: @@ -161,16 +161,25 @@ def mainline_version(self) -> str: return f"{self.major}.{self.minor}" @property - def sort_key(self) -> str: - suffix_weight = 0 - if self.suffix.startswith("rc"): - suffix_weight = 100 - elif self.suffix.startswith("m"): - suffix_weight = 50 + def suffix_weight(self) -> str: + # warning: using lexicographic order, letters doesn't have any meaning except for ordering + suffix_weight = "" + if self.is_ga: + suffix_weight = "QQ" + if self.is_rc: + suffix_weight = "LL" + elif self.suffix.startswith("-m"): + suffix_weight = "II" + + # internal versions are always lower than their GA/rc/m counterparts + if self.is_internal: + suffix_weight = suffix_weight[0] + "E" + + return suffix_weight - return ( - f"{self.major}.{self.minor}.{self.patch or 0}.{suffix_weight}.{self.suffix}" - ) + @property + def sort_key(self) -> str: + return f"{self.major}.{self.minor}.{self.patch or 0}.{self.suffix_weight}{self.suffix}" def __str__(self) -> str: """String representation of the version.""" @@ -184,21 +193,7 @@ def __lt__(self, other: "RedisVersion") -> bool: if not isinstance(other, RedisVersion): return NotImplemented - # Compare major.minor.patch first - self_tuple = (self.major, self.minor, self.patch or 0) - other_tuple = (other.major, other.minor, other.patch or 0) - - if self_tuple != other_tuple: - return self_tuple < other_tuple - - # If numeric parts are equal, compare suffixes - # Empty suffix (GA) comes after suffixes (milestones) - if not self.suffix and other.suffix: - return False - if self.suffix and not other.suffix: - return True - - return self.suffix < other.suffix + return self.sort_key < other.sort_key def __le__(self, other: "RedisVersion") -> bool: """Less than or equal comparison.""" From bd639e6d2297a18998ba93bdf0d36ddd417d8984 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 12:54:36 +0200 Subject: [PATCH 07/13] Make release_type package field instead of release field --- src/redis_release/bht/behaviours.py | 27 +++++++++------ src/redis_release/bht/ppas.py | 5 ++- src/redis_release/bht/state.py | 2 +- src/redis_release/bht/tree_factory.py | 1 + src/redis_release/cli.py | 49 ++++++++++++++++++++++++--- src/redis_release/models.py | 4 +-- src/redis_release/state_manager.py | 29 +++++++++++++--- src/redis_release/state_slack.py | 16 +++++---- 8 files changed, 104 insertions(+), 29 deletions(-) diff --git a/src/redis_release/bht/behaviours.py b/src/redis_release/bht/behaviours.py index 598ab5c..70a60e7 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -731,8 +731,8 @@ def __init__( super().__init__(name=f"{name} - debian", log_prefix=log_prefix) def update(self) -> Status: - if self.release_meta.release_type is not None: - self.workflow.inputs["release_type"] = self.release_meta.release_type.value + if self.package_meta.release_type is not None: + self.workflow.inputs["release_type"] = self.package_meta.release_type.value if self.release_meta.tag is not None: self.workflow.inputs["release_tag"] = self.release_meta.tag return Status.SUCCESS @@ -765,6 +765,8 @@ def update(self) -> Status: return Status.FAILURE msg = "" + if self.release_version.is_internal: + msg = "Hombebrew internal release detected" if self.release_version.is_rc: self.package_meta.homebrew_channel = HomebrewChannel.RC msg = "Homebrew channel detected: rc" @@ -1061,7 +1063,7 @@ def __init__( super().__init__(name=name, log_prefix=log_prefix) def update(self) -> Status: - if self.release_meta.release_type == ReleaseType.INTERNAL: + if self.package_meta.release_type == ReleaseType.INTERNAL: if self.package_meta.publish_internal_release: self.logger.debug( f"Internal release requires publishing: {self.release_meta.tag}" @@ -1077,28 +1079,33 @@ def update(self) -> Status: class DetectReleaseType(LoggingAction): def __init__( - self, name: str, release_meta: ReleaseMeta, log_prefix: str = "" + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", ) -> None: self.release_meta = release_meta + self.package_meta = package_meta super().__init__(name=name, log_prefix=log_prefix) def update(self) -> Status: - if self.release_meta.release_type is not None: + if self.package_meta.release_type is not None: if self.log_once( - "release_type_detected", self.release_meta.ephemeral.log_once_flags + "release_type_detected", self.package_meta.ephemeral.log_once_flags ): self.logger.info( - f"Detected release type: {self.release_meta.release_type}" + f"Detected release type: {self.package_meta.release_type}" ) return Status.SUCCESS if self.release_meta.tag and re.search(r"-int\d*$", self.release_meta.tag): - self.release_meta.release_type = ReleaseType.INTERNAL + self.package_meta.release_type = ReleaseType.INTERNAL else: - self.release_meta.release_type = ReleaseType.PUBLIC + self.package_meta.release_type = ReleaseType.PUBLIC self.log_once( "release_type_detected", self.release_meta.ephemeral.log_once_flags ) - self.logger.info(f"Detected release type: {self.release_meta.release_type}") + self.logger.info(f"Detected release type: {self.package_meta.release_type}") return Status.SUCCESS diff --git a/src/redis_release/bht/ppas.py b/src/redis_release/bht/ppas.py index fc2157b..c50c65f 100644 --- a/src/redis_release/bht/ppas.py +++ b/src/redis_release/bht/ppas.py @@ -137,12 +137,15 @@ def create_identify_target_ref_ppa( def create_detect_release_type_ppa( + package_meta: PackageMeta, release_meta: ReleaseMeta, log_prefix: str, ) -> Union[Selector, Sequence]: return create_PPA( "Detect Release Type", - DetectReleaseType("Detect Release Type", release_meta, log_prefix=log_prefix), + DetectReleaseType( + "Detect Release Type", package_meta, release_meta, log_prefix=log_prefix + ), ) diff --git a/src/redis_release/bht/state.py b/src/redis_release/bht/state.py index dbe80c3..f900721 100644 --- a/src/redis_release/bht/state.py +++ b/src/redis_release/bht/state.py @@ -143,6 +143,7 @@ class PackageMeta(BaseModel): serialization_hint: Literal["generic"] = "generic" package_type: Optional[PackageType] = None + release_type: Optional[ReleaseType] = None repo: str = "" ref: Optional[str] = None publish_internal_release: bool = False @@ -195,7 +196,6 @@ class ReleaseMeta(BaseModel): """Metadata for the release.""" tag: Optional[str] = None - release_type: Optional[ReleaseType] = None last_started_at: Optional[datetime] = None ephemeral: ReleaseMetaEphemeral = Field(default_factory=ReleaseMetaEphemeral) diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py index 16705aa..2671361 100644 --- a/src/redis_release/bht/tree_factory.py +++ b/src/redis_release/bht/tree_factory.py @@ -133,6 +133,7 @@ def create_workflow_complete_tree_branch( log_prefix, ) detect_release_type = create_detect_release_type_ppa( + package_meta, release_meta, log_prefix, ) diff --git a/src/redis_release/cli.py b/src/redis_release/cli.py index 469405a..9a14ba6 100644 --- a/src/redis_release/cli.py +++ b/src/redis_release/cli.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import List, Optional +from typing import Dict, List, Optional import typer from py_trees.display import render_dot_tree, unicode_tree @@ -30,6 +30,47 @@ logger = logging.getLogger(__name__) +def parse_force_release_type( + force_release_type_list: Optional[List[str]], +) -> Dict[str, ReleaseType]: + """Parse force_release_type arguments from 'package_name:release_type' format. + + Args: + force_release_type_list: List of strings in format 'package_name:release_type' + + Returns: + Dictionary mapping package names to ReleaseType + + Raises: + typer.BadParameter: If format is invalid or release type is unknown + """ + if not force_release_type_list: + return {} + + result = {} + for item in force_release_type_list: + if ":" not in item: + raise typer.BadParameter( + f"Invalid format '{item}'. Expected 'package_name:release_type' (e.g., 'docker:internal')" + ) + + package_name, release_type_str = item.split(":", 1) + package_name = package_name.strip() + release_type_str = release_type_str.strip().lower() + + try: + release_type = ReleaseType(release_type_str) + except ValueError: + valid_types = ", ".join([rt.value for rt in ReleaseType]) + raise typer.BadParameter( + f"Invalid release type '{release_type_str}'. Valid types: {valid_types}" + ) + + result[package_name] = release_type + + return result + + @app.command() def release_print( release_tag: str = typer.Argument(..., help="Release tag (e.g., 8.4-m01-int1)"), @@ -108,10 +149,10 @@ def release( tree_cutoff: int = typer.Option( 2000, "--tree-cutoff", "-m", help="Max number of ticks to run the tree for" ), - force_release_type: Optional[ReleaseType] = typer.Option( + force_release_type: Optional[List[str]] = typer.Option( None, "--force-release-type", - help="Force release type (public or internal)", + help="Force release type per package in format 'package_name:release_type' (e.g., 'docker:internal' or 'all:public'). Can be specified multiple times.", ), override_state_name: Optional[str] = typer.Option( None, @@ -139,7 +180,7 @@ def release( release_tag=release_tag, force_rebuild=force_rebuild or [], only_packages=only_packages or [], - force_release_type=force_release_type, + force_release_type=parse_force_release_type(force_release_type), override_state_name=override_state_name, slack_token=slack_token, slack_channel_id=slack_channel_id, diff --git a/src/redis_release/models.py b/src/redis_release/models.py index f8ee021..62dcbc6 100644 --- a/src/redis_release/models.py +++ b/src/redis_release/models.py @@ -3,7 +3,7 @@ import functools import re from enum import Enum -from typing import List, Optional +from typing import Dict, List, Optional from pydantic import BaseModel, Field @@ -235,7 +235,7 @@ class ReleaseArgs(BaseModel): release_tag: str force_rebuild: List[str] = Field(default_factory=list) only_packages: List[str] = Field(default_factory=list) - force_release_type: Optional[ReleaseType] = None + force_release_type: Dict[str, ReleaseType] = Field(default_factory=dict) override_state_name: Optional[str] = None slack_token: Optional[str] = None slack_channel_id: Optional[str] = None diff --git a/src/redis_release/state_manager.py b/src/redis_release/state_manager.py index fe2abe7..177e498 100644 --- a/src/redis_release/state_manager.py +++ b/src/redis_release/state_manager.py @@ -50,9 +50,6 @@ def __init__( self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") self.aws_session_token = os.getenv("AWS_SESSION_TOKEN") - # local state cache for dry run mode - self._local_state_cache = {} - @property def s3_client(self) -> Optional[boto3.client]: """Lazy initialization of S3 client.""" @@ -245,7 +242,31 @@ def apply_args(self, state: ReleaseState) -> None: if self.args.force_release_type: logger.info(f"Force release type: {self.args.force_release_type}") - state.meta.release_type = self.args.force_release_type + # Handle "all" keyword to apply to all packages + if "all" in self.args.force_release_type: + release_type = self.args.force_release_type["all"] + for package_name in state.packages: + state.packages[package_name].meta.release_type = release_type + logger.info( + f"Set release type for package '{package_name}': {release_type}" + ) + else: + # Set release type for specific packages + for ( + package_name, + release_type, + ) in self.args.force_release_type.items(): + if package_name in state.packages: + state.packages[package_name].meta.release_type = ( + release_type + ) + logger.info( + f"Set release type for package '{package_name}': {release_type}" + ) + else: + logger.warning( + f"Package '{package_name}' not found in state, skipping release type override" + ) def load(self) -> Optional[ReleaseState]: """Load state from storage backend.""" diff --git a/src/redis_release/state_slack.py b/src/redis_release/state_slack.py index 76157ee..5f6b8bf 100644 --- a/src/redis_release/state_slack.py +++ b/src/redis_release/state_slack.py @@ -83,19 +83,19 @@ def __init__( reply_broadcast: If True and thread_ts is set, also show in main channel """ self.client = WebClient(token=slack_token) - self.channel_id = slack_channel_id + self.channel_id: str = slack_channel_id self.thread_ts = thread_ts self.reply_broadcast = reply_broadcast self.message_ts: Optional[str] = None self.last_blocks_json: Optional[str] = None self.started_at = datetime.now(timezone.utc) - def format_package_name(self, package_name: str, state: ReleaseState) -> str: + def format_package_name(self, package_name: str, package: Package) -> str: """Format package name with capital letter and release type. Args: package_name: The raw package name - state: The ReleaseState to get release type from + package: The Package to get release type from Returns: Formatted package name with capital letter and release type in parentheses @@ -104,8 +104,8 @@ def format_package_name(self, package_name: str, state: ReleaseState) -> str: formatted = package_name.capitalize() # Add release type if available - if state.meta.release_type: - release_type_str = state.meta.release_type.value + if package.meta.release_type: + release_type_str = package.meta.release_type.value formatted = f"{formatted} ({release_type_str})" return formatted @@ -149,7 +149,9 @@ def update_message(self, state: ReleaseState) -> bool: response = self.client.chat_postMessage(**kwargs) self.message_ts = response["ts"] # Update channel_id from response (authoritative) - self.channel_id = response["channel"] + channel = response.get("channel") + if isinstance(channel, str): + self.channel_id = channel logger.info( f"Posted Slack message ts={self.message_ts}" + (f" in thread {self.thread_ts}" if self.thread_ts else "") @@ -231,7 +233,7 @@ def _make_blocks(self, state: ReleaseState) -> List[Dict[str, Any]]: # Process each package for package_name, package in sorted(state.packages.items()): # Format package name with capital letter and release type - formatted_name = self.format_package_name(package_name, state) + formatted_name = self.format_package_name(package_name, package) # Get workflow statuses build_status_emoji = self._get_status_emoji(package, package.build) From 64ff0eed99c9947ac654f70e202012eff9ba79d0 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 19:05:30 +0200 Subject: [PATCH 08/13] Massive fixes, new display stage, fix edge case for version --- src/redis_release/bht/behaviours.py | 137 +++++++++++++++++++------- src/redis_release/bht/composites.py | 27 ++++- src/redis_release/bht/state.py | 14 ++- src/redis_release/bht/tree.py | 35 +++++-- src/redis_release/bht/tree_factory.py | 97 +++++++++++++++--- src/redis_release/cli.py | 8 +- src/redis_release/models.py | 2 +- src/redis_release/state_display.py | 124 +++++++++++++++++------ src/redis_release/state_slack.py | 86 ++++++++++++++-- 9 files changed, 428 insertions(+), 102 deletions(-) diff --git a/src/redis_release/bht/behaviours.py b/src/redis_release/bht/behaviours.py index 70a60e7..0f1ba00 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -19,6 +19,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional +from py import log from py_trees.behaviour import Behaviour from py_trees.common import Status @@ -738,22 +739,51 @@ def update(self) -> Status: return Status.SUCCESS -class DetectHomebrewChannel(ReleaseAction): +class HomewbrewWorkflowInputs(ReleaseAction): def __init__( self, name: str, + workflow: Workflow, package_meta: HomebrewMeta, release_meta: ReleaseMeta, log_prefix: str = "", ) -> None: + self.workflow = workflow self.package_meta = package_meta self.release_meta = release_meta + super().__init__(name=f"{name} - homebrew", log_prefix=log_prefix) + + def update(self) -> Status: + if self.package_meta.release_type is not None: + self.workflow.inputs["release_type"] = self.package_meta.release_type.value + if self.release_meta.tag is not None: + self.workflow.inputs["release_tag"] = self.release_meta.tag + if self.package_meta.homebrew_channel is not None: + self.workflow.inputs["channel"] = self.package_meta.homebrew_channel.value + return Status.SUCCESS + + +class DetectHombrewReleaseAndChannel(ReleaseAction): + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.package_meta = package_meta + self.release_meta = release_meta + self.release_version: Optional[RedisVersion] = None super().__init__(name=name, log_prefix=log_prefix) def initialise(self) -> None: if self.release_meta.tag is None: self.logger.error("Release tag is not set") return + if self.release_version is not None: + return + + self.feedback_message = "" try: self.release_version = RedisVersion.parse(self.release_meta.tag) except ValueError as e: @@ -762,24 +792,37 @@ def initialise(self) -> None: def update(self) -> Status: if self.release_meta.tag is None: + logger.error("Release tag is not set") return Status.FAILURE - msg = "" - if self.release_version.is_internal: - msg = "Hombebrew internal release detected" - if self.release_version.is_rc: - self.package_meta.homebrew_channel = HomebrewChannel.RC - msg = "Homebrew channel detected: rc" - elif self.release_version.is_ga: - msg = "Homebrew channel detected: stable" - self.package_meta.homebrew_channel = HomebrewChannel.STABLE + if ( + self.package_meta.homebrew_channel is not None + and self.package_meta.release_type is not None + ): + pass else: - msg = "Homebrew channel not detected" + assert self.release_version is not None + if self.release_version.is_internal: + self.package_meta.release_type = ReleaseType.INTERNAL + self.package_meta.homebrew_channel = HomebrewChannel.RC + else: + if self.release_version.is_ga: + self.package_meta.release_type = ReleaseType.PUBLIC + self.package_meta.homebrew_channel = HomebrewChannel.STABLE + elif self.release_version.is_rc: + self.package_meta.release_type = ReleaseType.PUBLIC + self.package_meta.homebrew_channel = HomebrewChannel.RC + else: + self.package_meta.release_type = ReleaseType.INTERNAL + self.package_meta.homebrew_channel = HomebrewChannel.RC + self.feedback_message = f"release_type: {self.package_meta.release_type.value}, homebrew_channel: {self.package_meta.homebrew_channel.value}" if self.log_once( "homebrew_channel_detected", self.package_meta.ephemeral.log_once_flags ): - self.logger.info(msg) + self.logger.info( + f"Hombrew release_type: {self.package_meta.release_type}, homebrew_channel: {self.package_meta.homebrew_channel}" + ) return Status.SUCCESS @@ -810,6 +853,10 @@ def __init__( def initialise(self) -> None: """Initialize by validating inputs and starting download task.""" + if self.package_meta.ephemeral.is_version_acceptable is not None: + return + + self.feedback_message = "" # Validate homebrew_channel is set if self.package_meta.homebrew_channel is None: self.logger.error("Homebrew channel is not set") @@ -829,6 +876,10 @@ def initialise(self) -> None: self.logger.error("Release tag is not set") return + if self.package_meta.release_type is None: + self.logger.error("Package release type is not set") + return + try: self.release_version = RedisVersion.parse(self.release_meta.tag) self.logger.debug(f"Parsed release version: {self.release_version}") @@ -838,9 +889,9 @@ def initialise(self) -> None: # Determine which cask file to download based on channel if self.package_meta.homebrew_channel == HomebrewChannel.STABLE: - cask_file = "Casks/redis.rb" + cask_file = "zCasks/redis.rb" elif self.package_meta.homebrew_channel == HomebrewChannel.RC: - cask_file = "Casks/redis-rc.rb" + cask_file = "zCasks/redis-rc.rb" else: self.logger.error( f"Unknown homebrew channel: {self.package_meta.homebrew_channel}" @@ -860,14 +911,10 @@ def initialise(self) -> None: def update(self) -> Status: """Process downloaded cask file and classify version.""" - try: - # Validate prerequisites - if self.package_meta.homebrew_channel is None: - return Status.FAILURE - - if self.release_version is None: - return Status.FAILURE + if self.package_meta.ephemeral.is_version_acceptable is not None: + return Status.SUCCESS + try: assert self.task is not None # Wait for download to complete @@ -882,8 +929,6 @@ def update(self) -> Status: # Parse version from cask file # Look for: version "X.Y.Z" - import re - version_match = re.search( r'^\s*version\s+"([^"]+)"', cask_content, re.MULTILINE ) @@ -905,27 +950,35 @@ def update(self) -> Status: return Status.FAILURE # Compare versions: cask version >= release version means acceptable - if self.cask_version >= self.release_version: # type: ignore[operator] + assert self.release_version is not None + self.package_meta.remote_version = str(self.cask_version) + log_prepend = "" + prepend_color = "green" + if self.release_version >= self.cask_version: self.package_meta.ephemeral.is_version_acceptable = True - self.logger.info( - f"[green]Version acceptable:[/green] cask {self.cask_version} >= release {self.release_version}" + self.feedback_message = ( + f"release {self.release_version} >= cask {self.cask_version}" ) + log_prepend = "Version acceptable: " else: self.package_meta.ephemeral.is_version_acceptable = False + log_prepend = "Version NOT acceptable: " + prepend_color = "yellow" + self.feedback_message = ( + f"release {self.release_version} < cask {self.cask_version}" + ) + if self.log_once( + "homebrew_version_classified", + self.package_meta.ephemeral.log_once_flags, + ): self.logger.info( - f"[yellow]Version not acceptable:[/yellow] cask {self.cask_version} < release {self.release_version}" + f"[{prepend_color}]{log_prepend}{self.feedback_message}[/]" ) - return Status.SUCCESS except Exception as e: return self.log_exception_and_return_failure(e) - def terminate(self, new_status: Status) -> None: - """Terminate the behavior.""" - # TODO: Cancel task if needed - pass - ### Conditions ### @@ -1120,3 +1173,21 @@ def update(self) -> Status: if self.package_meta.ephemeral.force_rebuild: return Status.SUCCESS return Status.FAILURE + + +class NeedToReleaseHomebrew(LoggingAction): + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.package_meta = package_meta + self.release_meta = release_meta + super().__init__(name=name, log_prefix=log_prefix) + + def update(self) -> Status: + if self.package_meta.ephemeral.is_version_acceptable is True: + return Status.SUCCESS + return Status.FAILURE diff --git a/src/redis_release/bht/composites.py b/src/redis_release/bht/composites.py index 0a43a8b..327c4dc 100644 --- a/src/redis_release/bht/composites.py +++ b/src/redis_release/bht/composites.py @@ -21,6 +21,7 @@ from ..github_client_async import GitHubClientAsync from .behaviours import ( + ClassifyHomebrewVersion, ExtractArtifactResult, GetWorkflowArtifactsList, IdentifyTargetRef, @@ -32,7 +33,7 @@ from .behaviours import TriggerWorkflow as TriggerWorkflow from .behaviours import UpdateWorkflowStatusUntilCompletion from .decorators import ConditionGuard, FlagGuard, StatusFlagGuard -from .state import Package, PackageMeta, ReleaseMeta, Workflow +from .state import HomebrewMeta, Package, PackageMeta, ReleaseMeta, Workflow class ParallelBarrier(Composite): @@ -393,3 +394,27 @@ def __init__( guard_status=Status.FAILURE, log_prefix=log_prefix, ) + + +class ClassifyHomebrewVersionGuarded(StatusFlagGuard): + def __init__( + self, + name: str, + package_meta: HomebrewMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str = "", + ) -> None: + super().__init__( + None if name == "" else name, + ClassifyHomebrewVersion( + "Classify Homebrew Version", + package_meta, + release_meta, + github_client, + log_prefix=log_prefix, + ), + package_meta.ephemeral, + "classify_remote_versions", + log_prefix=log_prefix, + ) diff --git a/src/redis_release/bht/state.py b/src/redis_release/bht/state.py index f900721..1784895 100644 --- a/src/redis_release/bht/state.py +++ b/src/redis_release/bht/state.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -SUPPORTED_STATE_VERSION = 2 +SUPPORTED_STATE_VERSION = 3 class WorkflowEphemeral(BaseModel): @@ -155,6 +155,11 @@ class HomebrewMeta(PackageMeta): serialization_hint: Literal["homebrew"] = "homebrew" # type: ignore[assignment] homebrew_channel: Optional[HomebrewChannel] = None + # remote_version field is for status display only (e.g. to pair with + # classify_remote_versions flag) actual decision is based on + # ephemeral.is_version_acceptable which is reset on each run to always + # reflect recent remote version + remote_version: Optional[str] = None ephemeral: HomebrewMetaEphemeral = Field(default_factory=HomebrewMetaEphemeral) # type: ignore[assignment] @@ -163,6 +168,11 @@ class SnapMeta(PackageMeta): serialization_hint: Literal["snap"] = "snap" # type: ignore[assignment] snap_risk_level: Optional[SnapRiskLevel] = None + # remote_version field is for status display only (e.g. to pair with + # classify_remote_versions flag) actual decision is based on + # ephemeral.is_version_acceptable which is reset on each run to always + # reflect recent remote version + remote_version: Optional[str] = None ephemeral: SnapMetaEphemeral = Field(default_factory=SnapMetaEphemeral) # type: ignore[assignment] @@ -203,7 +213,7 @@ class ReleaseMeta(BaseModel): class ReleaseState(BaseModel): """Release state adapted for behavior tree usage.""" - version: int = 2 + version: int = SUPPORTED_STATE_VERSION meta: ReleaseMeta = Field(default_factory=ReleaseMeta) packages: Dict[str, Package] = Field(default_factory=dict) diff --git a/src/redis_release/bht/tree.py b/src/redis_release/bht/tree.py index 9feb3d4..b501925 100644 --- a/src/redis_release/bht/tree.py +++ b/src/redis_release/bht/tree.py @@ -19,7 +19,7 @@ from py_trees.visitors import SnapshotVisitor from rich.text import Text -from ..config import Config +from ..config import Config, PackageConfig from ..github_client_async import GitHubClientAsync from ..models import PackageType, ReleaseArgs from ..state_display import print_state_table @@ -42,7 +42,14 @@ create_workflow_completion_ppa, create_workflow_success_ppa, ) -from .state import Package, PackageMeta, ReleaseMeta, ReleaseState, Workflow +from .state import ( + SUPPORTED_STATE_VERSION, + Package, + PackageMeta, + ReleaseMeta, + ReleaseState, + Workflow, +) from .tree_factory import get_factory logger = logging.getLogger(__name__) @@ -263,16 +270,24 @@ def create_by_name(self, name: str) -> Union[Selector, Sequence, Behaviour]: available = ", ".join(self.get_names()) raise ValueError(f"Unknown name '{name}'. Available options: {available}") + config = Config( + version=SUPPORTED_STATE_VERSION, + packages={ + "inspected": PackageConfig( + repo="test/repo", + package_type=self.package_type, + build_workflow="build.yml", + publish_workflow="publish.yml", + ) + }, + ) + state = ReleaseState.from_config(config) # Create mock objects for PPA/branch creation - workflow = Workflow(workflow_file="test.yml", inputs={}) - package_meta = PackageMeta(repo="redis/redis", ref="main") - release_meta = ReleaseMeta(tag=self.release_tag) + workflow = state.packages["inspected"].build + package_meta = state.packages["inspected"].meta + release_meta = state.meta github_client = GitHubClientAsync(token="dummy") - package = Package( - meta=package_meta, - build=workflow, - publish=Workflow(workflow_file="publish.yml", inputs={}), - ) + package = state.packages["inspected"] log_prefix = "test" # Create and return the requested branch/PPA diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py index 2671361..9173284 100644 --- a/src/redis_release/bht/tree_factory.py +++ b/src/redis_release/bht/tree_factory.py @@ -16,11 +16,15 @@ from .backchain import create_PPA, latch_chains from .behaviours import ( ClassifyHomebrewVersion, - DetectHomebrewChannel, + DebianWorkflowInputs, + DetectHombrewReleaseAndChannel, GenericWorkflowInputs, + HomewbrewWorkflowInputs, NeedToPublishRelease, + NeedToReleaseHomebrew, ) from .composites import ( + ClassifyHomebrewVersionGuarded, ResetPackageStateGuarded, RestartPackageGuarded, RestartWorkflowGuarded, @@ -47,7 +51,7 @@ logger = logging.getLogger(__name__) -class GenericFactory(ABC): +class GenericPackageFactory(ABC): """Default factory for packages without specific customizations.""" def create_package_release_goal_tree_branch( @@ -347,7 +351,7 @@ def create_extract_result_tree_branch( return extract_artifact_result -class DebianFactory(GenericFactory): +class DebianFactory(GenericPackageFactory): """Factory for Debian packages. Inherits from GenericFactory and overrides only the methods that need @@ -362,7 +366,6 @@ def create_build_workflow_inputs( release_meta: ReleaseMeta, log_prefix: str, ) -> Behaviour: - from .behaviours import DebianWorkflowInputs return DebianWorkflowInputs( name, workflow, package_meta, release_meta, log_prefix=log_prefix @@ -383,13 +386,13 @@ def create_publish_workflow_inputs( ) -class DockerFactory(GenericFactory): +class DockerFactory(GenericPackageFactory): """Factory for Docker packages.""" pass -class HomebrewFactory(GenericFactory): +class HomebrewFactory(GenericPackageFactory): def create_package_release_goal_tree_branch( self, package: Package, @@ -398,27 +401,39 @@ def create_package_release_goal_tree_branch( github_client: GitHubClientAsync, package_name: str, ) -> Union[Selector, Sequence]: - logger.error("Creating Homebrew package release goal tree branch") package_release = self.create_package_release_tree_branch( package, release_meta, default_package, github_client, package_name ) + need_to_release = NeedToReleaseHomebrew( + "Need To Release?", + cast(HomebrewMeta, package.meta), + release_meta, + log_prefix=package_name, + ) release_goal = Selector( - f"Package Release {package_name} Goal", + f"Release Workflows {package_name} Goal", memory=False, - children=[AlwaysFailure("Yes"), package_release], + children=[Inverter("Not", need_to_release), package_release], + ) + reset_package_state = ResetPackageStateGuarded( + "", + package, + default_package, + log_prefix=package_name, ) return Sequence( - f"Release Validation {package_name}", + f"Release {package_name}", memory=False, children=[ - DetectHomebrewChannel( + reset_package_state, + DetectHombrewReleaseAndChannel( "Detect Homebrew Channel", cast(HomebrewMeta, package.meta), release_meta, log_prefix=package_name, ), - ClassifyHomebrewVersion( - "Classify Homebrew Version", + ClassifyHomebrewVersionGuarded( + "", cast(HomebrewMeta, package.meta), release_meta, github_client, @@ -428,6 +443,39 @@ def create_package_release_goal_tree_branch( ], ) + def create_package_release_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: + build = self.create_build_workflow_tree_branch( + package, + release_meta, + default_package, + github_client, + package_name, + ) + build.name = f"Build {package_name}" + publish = self.create_publish_workflow_tree_branch( + package.build, + package.publish, + package.meta, + release_meta, + default_package.publish, + github_client, + package_name, + ) + publish.name = f"Publish {package_name}" + package_release = Sequence( + f"Execute Workflows {package_name}", + memory=False, + children=[build, publish], + ) + return package_release + def create_workflow_complete_tree_branch( self, workflow: Workflow, @@ -471,18 +519,35 @@ def create_workflow_complete_tree_branch( ) return workflow_complete + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + + return HomewbrewWorkflowInputs( + name, + workflow, + cast(HomebrewMeta, package_meta), + release_meta, + log_prefix=log_prefix, + ) + # Factory registry -_FACTORIES: Dict[PackageType, GenericFactory] = { +_FACTORIES: Dict[PackageType, GenericPackageFactory] = { PackageType.DEBIAN: DebianFactory(), PackageType.DOCKER: DockerFactory(), PackageType.HOMEBREW: HomebrewFactory(), } -_DEFAULT_FACTORY = GenericFactory() +_DEFAULT_FACTORY = GenericPackageFactory() -def get_factory(package_type: Optional[PackageType]) -> GenericFactory: +def get_factory(package_type: Optional[PackageType]) -> GenericPackageFactory: """Get the factory for a given package type. Args: diff --git a/src/redis_release/cli.py b/src/redis_release/cli.py index 9a14ba6..62ed874 100644 --- a/src/redis_release/cli.py +++ b/src/redis_release/cli.py @@ -116,7 +116,7 @@ def release_print( render_dot_tree(branch) print(unicode_tree(branch)) except ValueError as e: - logger.error(f"[red]Error: {e}[/red]") + logger.error(f"[red]Error: {e}[/red]", exc_info=True) raise typer.Exit(1) else: # Print full release tree @@ -197,6 +197,11 @@ def status( config_file: Optional[str] = typer.Option( None, "--config", "-c", help="Path to config file (default: config.yaml)" ), + override_state_name: Optional[str] = typer.Option( + None, + "--override-state-name", + help="Custom state name to use instead of release tag, to be able to make test runs without affecting production state", + ), slack: bool = typer.Option(False, "--slack", help="Post status to Slack"), slack_channel_id: Optional[str] = typer.Option( None, @@ -218,6 +223,7 @@ def status( args = ReleaseArgs( release_tag=release_tag, force_rebuild=[], + override_state_name=override_state_name, ) with StateManager( diff --git a/src/redis_release/models.py b/src/redis_release/models.py index 62dcbc6..de7975c 100644 --- a/src/redis_release/models.py +++ b/src/redis_release/models.py @@ -173,7 +173,7 @@ def suffix_weight(self) -> str: # internal versions are always lower than their GA/rc/m counterparts if self.is_internal: - suffix_weight = suffix_weight[0] + "E" + suffix_weight = suffix_weight[:1] + "E" return suffix_weight diff --git a/src/redis_release/state_display.py b/src/redis_release/state_display.py index 988475c..d551247 100644 --- a/src/redis_release/state_display.py +++ b/src/redis_release/state_display.py @@ -1,7 +1,7 @@ """Console display utilities for release state.""" from enum import Enum -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union from py_trees import common from py_trees.common import Status @@ -10,7 +10,15 @@ from redis_release.models import WorkflowConclusion -from .bht.state import Package, ReleaseState, Workflow +from .bht.state import ( + HomebrewMeta, + HomebrewMetaEphemeral, + Package, + ReleaseState, + SnapMeta, + SnapMetaEphemeral, + Workflow, +) # See WorkflowEphemeral for more details on the flags and steps @@ -120,6 +128,37 @@ def get_workflow_status( return (s, steps_status) return (StepStatus.SUCCEEDED, steps_status) + @staticmethod + def get_release_validation_status( + meta: Union[HomebrewMeta, SnapMeta], + ) -> Tuple[StepStatus, List[Tuple[StepStatus, str, Optional[str]]]]: + """Get release validation status for Homebrew or Snap packages. + + This method checks validation steps specific to Homebrew and Snap packages, + such as remote version classification. + + Args: + meta: The package metadata (HomebrewMeta or SnapMeta) + + Returns: + Tuple of (overall_status, list of (step_status, step_name, step_message)) + """ + steps_status: List[Tuple[StepStatus, str, Optional[str]]] = [] + steps = [ + ( + meta.remote_version is not None, + meta.ephemeral.classify_remote_versions, + "Classify remote versions", + None, + ), + ] + for result, status_flag, name, status_msg in steps: + s = DisplayModel.get_step_status(result, status_flag) + steps_status.append((s, name, status_msg)) + if s != StepStatus.SUCCEEDED: + return (s, steps_status) + return (StepStatus.SUCCEEDED, steps_status) + class ConsoleStatePrinter: """Handles printing of release state to console using Rich tables.""" @@ -161,7 +200,21 @@ def print_state_table(self, state: ReleaseState) -> None: # Process each package for package_name, package in sorted(state.packages.items()): # Determine build status - build_status = self.get_workflow_status_display(package, package.build) + build_status = "" + if ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + # to avoid creating new column validation status is counted as part of build workflow + status, _ = DisplayModel.get_release_validation_status(package.meta) # type: ignore + if status != StepStatus.SUCCEEDED: + build_status = self.get_step_status_display(status) + else: + build_status = self.get_workflow_status_display( + package, package.build + ) + else: + build_status = self.get_workflow_status_display(package, package.build) # Determine publish status publish_status = self.get_workflow_status_display(package, package.publish) @@ -193,43 +246,29 @@ def get_workflow_status_display(self, package: Package, workflow: Workflow) -> s Rich-formatted status string """ workflow_status = DisplayModel.get_workflow_status(package, workflow) - if workflow_status[0] == StepStatus.SUCCEEDED: + return self.get_step_status_display(workflow_status[0]) + + def get_step_status_display(self, step_status: StepStatus) -> str: + if step_status == StepStatus.SUCCEEDED: return "[bold green]✓ Success[/bold green]" - elif workflow_status[0] == StepStatus.RUNNING: + elif step_status == StepStatus.RUNNING: return "[bold yellow]⏳ In Progress[/bold yellow]" - elif workflow_status[0] == StepStatus.NOT_STARTED: + elif step_status == StepStatus.NOT_STARTED: return "[dim]Not Started[/dim]" - elif workflow_status[0] == StepStatus.INCORRECT: + elif step_status == StepStatus.INCORRECT: return "[bold red]✗ Invalid state![/bold red]" return "[bold red]✗ Failed[/bold red]" - def collect_workflow_details( - self, package: Package, workflow: Workflow, prefix: str + def collect_text_details( + self, steps: List[Tuple[StepStatus, str, Optional[str]]], prefix: str ) -> List[str]: - """Collect details from a workflow using bottom-up approach. - - Shows successes until the first failure, then stops. - Bottom-up means: trigger → identify → timeout → conclusion → artifacts → result - - Args: - package: The package containing the workflow - workflow: The workflow to check - prefix: Prefix for detail messages (e.g., "Build" or "Publish") - - Returns: - List of detail strings - """ details: List[str] = [] - workflow_status = DisplayModel.get_workflow_status(package, workflow) - if workflow_status[0] == StepStatus.NOT_STARTED: - return details - - details.append(f"{prefix} Workflow") + details.append(f"{prefix}") indent = " " * 2 - for step_status, step_name, step_message in workflow_status[1]: + for step_status, step_name, step_message in steps: if step_status == StepStatus.SUCCEEDED: details.append(f"{indent}[green]✓ {step_name}[/green]") elif step_status == StepStatus.RUNNING: @@ -254,10 +293,31 @@ def collect_details(self, package: Package) -> str: """ details: List[str] = [] - details.extend(self.collect_workflow_details(package, package.build, "Build")) - details.extend( - self.collect_workflow_details(package, package.publish, "Publish") - ) + build_status = DisplayModel.get_workflow_status(package, package.build) + if ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + validation_status, validation_steps = ( + DisplayModel.get_release_validation_status(package.meta) # type: ignore + ) + # Show any validation steps only when build has started or validation has failed + if ( + validation_status != StepStatus.NOT_STARTED + and build_status[0] != StepStatus.NOT_STARTED + ) or (validation_status == StepStatus.FAILED): + details.extend( + self.collect_text_details(validation_steps, "Release Validation") + ) + + build_status = DisplayModel.get_workflow_status(package, package.build) + if build_status[0] != StepStatus.NOT_STARTED: + details.extend(self.collect_text_details(build_status[1], "Build Workflow")) + publish_status = DisplayModel.get_workflow_status(package, package.publish) + if publish_status[0] != StepStatus.NOT_STARTED: + details.extend( + self.collect_text_details(publish_status[1], "Publish Workflow") + ) return "\n".join(details) diff --git a/src/redis_release/state_slack.py b/src/redis_release/state_slack.py index 5f6b8bf..4286c6a 100644 --- a/src/redis_release/state_slack.py +++ b/src/redis_release/state_slack.py @@ -11,7 +11,15 @@ from redis_release.state_display import DisplayModel, StepStatus -from .bht.state import Package, ReleaseState, Workflow +from .bht.state import ( + HomebrewMeta, + HomebrewMetaEphemeral, + Package, + ReleaseState, + SnapMeta, + SnapMetaEphemeral, + Workflow, +) logger = logging.getLogger(__name__) @@ -296,6 +304,8 @@ def _make_blocks(self, state: ReleaseState) -> List[Dict[str, Any]]: def _get_status_emoji(self, package: Package, workflow: Workflow) -> str: """Get emoji status for a workflow. + For build workflow of Homebrew/Snap packages, checks validation status first. + Args: package: The package containing the workflow workflow: The workflow to check @@ -303,9 +313,31 @@ def _get_status_emoji(self, package: Package, workflow: Workflow) -> str: Returns: Emoji status string """ + # For build workflow of Homebrew/Snap packages, check validation status first + if workflow == package.build and ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + # Check validation status first + validation_status, _ = DisplayModel.get_release_validation_status( + package.meta # type: ignore + ) + if validation_status != StepStatus.SUCCEEDED: + return self._get_step_status_emoji(validation_status) + + # Check workflow status workflow_status = DisplayModel.get_workflow_status(package, workflow) - status = workflow_status[0] + return self._get_step_status_emoji(workflow_status[0]) + + def _get_step_status_emoji(self, status: StepStatus) -> str: + """Convert step status to emoji string. + Args: + status: The step status + + Returns: + Emoji status string + """ if status == StepStatus.SUCCEEDED: return "✅ Success" elif status == StepStatus.RUNNING: @@ -322,6 +354,8 @@ def _collect_workflow_details_slack( ) -> str: """Collect workflow step details for Slack display. + For build workflow of Homebrew/Snap packages, includes validation details. + Args: package: The package containing the workflow workflow: The workflow to check @@ -329,13 +363,53 @@ def _collect_workflow_details_slack( Returns: Formatted string of workflow steps """ + details: List[str] = [] + workflow_status = DisplayModel.get_workflow_status(package, workflow) - if workflow_status[0] == StepStatus.NOT_STARTED: - return "" + # For build workflow of Homebrew/Snap packages, include validation details + if workflow == package.build and ( + type(package.meta.ephemeral) == HomebrewMetaEphemeral + or type(package.meta.ephemeral) == SnapMetaEphemeral + ): + validation_status, validation_steps = ( + DisplayModel.get_release_validation_status(package.meta) # type: ignore + ) + # Show any validation steps only when build has started or validation has failed + if ( + validation_status != StepStatus.NOT_STARTED + and workflow_status[0] != StepStatus.NOT_STARTED + ) or (validation_status == StepStatus.FAILED): + details.extend( + self._format_steps_for_slack(validation_steps, "Release Validation") + ) + + # Add workflow details + if workflow_status[0] != StepStatus.NOT_STARTED: + workflow_name = ( + "Build Workflow" if workflow == package.build else "Publish Workflow" + ) + details.extend( + self._format_steps_for_slack(workflow_status[1], workflow_name) + ) + + return "\n".join(details) + def _format_steps_for_slack( + self, steps: List[Tuple[StepStatus, str, Optional[str]]], prefix: str + ) -> List[str]: + """Format step details for Slack display. + + Args: + steps: List of (step_status, step_name, step_message) tuples + prefix: Section prefix/title + + Returns: + List of formatted step strings + """ details: List[str] = [] + details.append(f"*{prefix}*") - for step_status, step_name, step_message in workflow_status[1]: + for step_status, step_name, step_message in steps: if step_status == StepStatus.SUCCEEDED: details.append(f"• ✅ {step_name}") elif step_status == StepStatus.RUNNING: @@ -347,4 +421,4 @@ def _collect_workflow_details_slack( details.append(f"• ❌ {step_name}{msg}") break - return "\n".join(details) + return details From ee003bd69a52bba0f38cdad473ea194cc1318b7c Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 19:06:09 +0200 Subject: [PATCH 09/13] Tests for redis version --- src/tests/test_redis_version.py | 164 ++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/tests/test_redis_version.py diff --git a/src/tests/test_redis_version.py b/src/tests/test_redis_version.py new file mode 100644 index 0000000..bf781c9 --- /dev/null +++ b/src/tests/test_redis_version.py @@ -0,0 +1,164 @@ +"""Tests for data models.""" + +import pytest + +from redis_release.models import RedisVersion + + +class TestRedisVersion: + """Tests for RedisVersion model.""" + + def test_parse_basic_version(self): + """Test parsing basic version strings.""" + version = RedisVersion.parse("8.2.1") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "" + + def test_parse_version_with_v_prefix(self): + """Test parsing version with 'v' prefix.""" + version = RedisVersion.parse("v8.2.1") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "" + + def test_parse_version_with_suffix(self): + """Test parsing version with suffix.""" + version = RedisVersion.parse("8.2.1-m01") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "-m01" + + def test_parse_version_without_patch(self): + """Test parsing version without patch number.""" + version = RedisVersion.parse("8.2") + assert version.major == 8 + assert version.minor == 2 + assert version.patch is None + assert version.suffix == "" + + def test_parse_eol_version(self): + """Test parsing EOL version.""" + version = RedisVersion.parse("7.4.0-eol") + assert version.major == 7 + assert version.minor == 4 + assert version.patch == 0 + assert version.suffix == "-eol" + assert version.is_eol is True + + def test_parse_rc_internal_version(self): + """Test parsing RC internal version.""" + version = RedisVersion.parse("8.2.1-rc2-int3") + assert version.major == 8 + assert version.minor == 2 + assert version.patch == 1 + assert version.suffix == "-rc2-int3" + assert version.is_rc is True + assert version.is_internal is True + assert len(version.sort_key) > 0 + + version = RedisVersion.parse("8.4-int") + assert version.major == 8 + assert version.minor == 4 + assert version.patch == None + assert version.suffix == "-int" + assert version.is_internal is True + assert len(version.sort_key) > 0 + + def test_parse_invalid_version(self): + """Test parsing invalid version strings.""" + with pytest.raises(ValueError): + RedisVersion.parse("invalid") + + with pytest.raises(ValueError): + RedisVersion.parse("0.1.0") # Major version must be >= 1 + + def test_is_milestone(self): + """Test milestone detection.""" + ga_version = RedisVersion.parse("8.2.1") + milestone_version = RedisVersion.parse("8.2.1-m01") + + assert ga_version.is_milestone is False + assert milestone_version.is_milestone is True + + def test_mainline_version(self): + """Test mainline version property.""" + version = RedisVersion.parse("8.2.1-m01") + assert version.mainline_version == "8.2" + + def test_string_representation(self): + """Test string representation.""" + version1 = RedisVersion.parse("8.2.1") + version2 = RedisVersion.parse("8.2.1-m01") + version3 = RedisVersion.parse("8.2") + + assert str(version1) == "8.2.1" + assert str(version2) == "8.2.1-m01" + assert str(version3) == "8.2" + + def test_version_comparison(self): + """Test version comparison for sorting.""" + v8_2_1 = RedisVersion.parse("8.2.1") + v8_2_2 = RedisVersion.parse("8.2.2") + v8_2_1_m_01 = RedisVersion.parse("8.2.1-m01") + v8_2_1_rc_01 = RedisVersion.parse("8.2.1-rc01") + v8_2_1_rc_01_int_1 = RedisVersion.parse("8.2.1-rc01-int1") + v8_3_0 = RedisVersion.parse("8.3.0") + v8_3_0_rc_1 = RedisVersion.parse("8.3.0-rc1") + v8_3_0_rc_1_int_1 = RedisVersion.parse("8.3.0-rc1-int1") + v8_3_0_rc_1_int_2 = RedisVersion.parse("8.3.0-rc1-int2") + v8_4 = RedisVersion.parse("8.4") + v8_4_rc_1 = RedisVersion.parse("8.4-rc1") + v8_6_int = RedisVersion.parse("8.6-int") + + # Test numeric comparison + assert v8_2_1 < v8_2_2 + assert v8_2_2 < v8_3_0 + + # Test milestone vs GA (GA comes after milestone) + assert v8_2_1_m_01 < v8_2_1 + + assert v8_3_0_rc_1 < v8_3_0 + + assert v8_2_1_rc_01 > v8_2_1_m_01 + assert v8_2_1_rc_01_int_1 > v8_2_1_m_01 + assert v8_2_1_rc_01_int_1 < v8_2_1_rc_01 + + assert v8_3_0_rc_1_int_1 < v8_3_0_rc_1_int_2 + + assert v8_3_0_rc_1 > v8_3_0_rc_1_int_1 + assert v8_3_0_rc_1 > v8_3_0_rc_1_int_2 + + # Test sorting + versions = [ + v8_3_0, + v8_2_1, + v8_2_1_m_01, + v8_2_2, + v8_3_0_rc_1, + v8_3_0_rc_1_int_1, + v8_3_0_rc_1_int_2, + v8_6_int, + v8_4, + v8_4_rc_1, + v8_2_1_rc_01, + v8_2_1_rc_01_int_1, + ] + sorted_versions = sorted(versions) + assert sorted_versions == [ + v8_2_1_m_01, + v8_2_1_rc_01_int_1, + v8_2_1_rc_01, + v8_2_1, + v8_2_2, + v8_3_0_rc_1_int_1, + v8_3_0_rc_1_int_2, + v8_3_0_rc_1, + v8_3_0, + v8_4_rc_1, + v8_4, + v8_6_int, + ] From c9f18065dffdd6b4d6a27c03e4edc0ef28bbd2ea Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 20:22:04 +0200 Subject: [PATCH 10/13] Return correct path to Casks --- src/redis_release/bht/behaviours.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/redis_release/bht/behaviours.py b/src/redis_release/bht/behaviours.py index 0f1ba00..0a15bdd 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -889,9 +889,9 @@ def initialise(self) -> None: # Determine which cask file to download based on channel if self.package_meta.homebrew_channel == HomebrewChannel.STABLE: - cask_file = "zCasks/redis.rb" + cask_file = "Casks/redis.rb" elif self.package_meta.homebrew_channel == HomebrewChannel.RC: - cask_file = "zCasks/redis-rc.rb" + cask_file = "Casks/redis-rc.rb" else: self.logger.error( f"Unknown homebrew channel: {self.package_meta.homebrew_channel}" From a1c374a2254898604e65e344c7a28bd5a083d97c Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 21:03:37 +0200 Subject: [PATCH 11/13] RPM factory, rewire WorkflowInput classes --- config.yaml | 15 +++++++++++++-- src/redis_release/bht/behaviours.py | 18 +++++++++++------- src/redis_release/bht/tree_factory.py | 27 +++++++++++---------------- src/redis_release/cli.py | 2 +- src/redis_release/state_manager.py | 1 - 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/config.yaml b/config.yaml index c6098ce..3e513b5 100644 --- a/config.yaml +++ b/config.yaml @@ -1,7 +1,7 @@ version: 1 packages: docker: - # available types: docker, debian + # available types: docker, debian, homebrew package_type: docker # repo where the workflow is started repo: redis/docker-library-redis @@ -33,7 +33,7 @@ packages: publish_timeout_minutes: 10 publish_inputs: {} rpm: - package_type: debian + package_type: rpm repo: redis/redis-rpm build_workflow: release_build_and_test.yml build_inputs: {} @@ -41,3 +41,14 @@ packages: publish_workflow: release_publish.yml publish_timeout_minutes: 10 publish_inputs: {} + homebrew: + package_type: homebrew + repo: redis/homebrew-redis + # homebrew has one fixed release branch: main + ref: main + build_workflow: release_build_and_test.yml + build_inputs: {} + publish_internal_release: yes + publish_workflow: release_publish.yml + publish_timeout_minutes: 10 + publish_inputs: {} diff --git a/src/redis_release/bht/behaviours.py b/src/redis_release/bht/behaviours.py index 0a15bdd..b18c4a5 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -711,13 +711,21 @@ def __init__( self.workflow = workflow self.package_meta = package_meta self.release_meta = release_meta - super().__init__(name=name, log_prefix=log_prefix) + super().__init__(name=f"{name} - debian", log_prefix=log_prefix) def update(self) -> Status: + if self.package_meta.release_type is not None: + self.workflow.inputs["release_type"] = self.package_meta.release_type.value + if self.release_meta.tag is not None: + self.workflow.inputs["release_tag"] = self.release_meta.tag return Status.SUCCESS -class DebianWorkflowInputs(ReleaseAction): +class DockerWorkflowInputs(ReleaseAction): + """ + Docker uses only release_tag input which is set automatically in TriggerWorkflow + """ + def __init__( self, name: str, @@ -729,13 +737,9 @@ def __init__( self.workflow = workflow self.package_meta = package_meta self.release_meta = release_meta - super().__init__(name=f"{name} - debian", log_prefix=log_prefix) + super().__init__(name=name, log_prefix=log_prefix) def update(self) -> Status: - if self.package_meta.release_type is not None: - self.workflow.inputs["release_type"] = self.package_meta.release_type.value - if self.release_meta.tag is not None: - self.workflow.inputs["release_tag"] = self.release_meta.tag return Status.SUCCESS diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py index 9173284..fde3571 100644 --- a/src/redis_release/bht/tree_factory.py +++ b/src/redis_release/bht/tree_factory.py @@ -15,9 +15,8 @@ from ..models import PackageType from .backchain import create_PPA, latch_chains from .behaviours import ( - ClassifyHomebrewVersion, - DebianWorkflowInputs, DetectHombrewReleaseAndChannel, + DockerWorkflowInputs, GenericWorkflowInputs, HomewbrewWorkflowInputs, NeedToPublishRelease, @@ -351,12 +350,8 @@ def create_extract_result_tree_branch( return extract_artifact_result -class DebianFactory(GenericPackageFactory): - """Factory for Debian packages. - - Inherits from GenericFactory and overrides only the methods that need - Debian-specific behavior. - """ +class DockerFactory(GenericPackageFactory): + """Factory for Docker packages.""" def create_build_workflow_inputs( self, @@ -366,8 +361,7 @@ def create_build_workflow_inputs( release_meta: ReleaseMeta, log_prefix: str, ) -> Behaviour: - - return DebianWorkflowInputs( + return DockerWorkflowInputs( name, workflow, package_meta, release_meta, log_prefix=log_prefix ) @@ -379,16 +373,16 @@ def create_publish_workflow_inputs( release_meta: ReleaseMeta, log_prefix: str, ) -> Behaviour: - from .behaviours import DebianWorkflowInputs - - return DebianWorkflowInputs( + return DockerWorkflowInputs( name, workflow, package_meta, release_meta, log_prefix=log_prefix ) -class DockerFactory(GenericPackageFactory): - """Factory for Docker packages.""" +class DebianFactory(GenericPackageFactory): + pass + +class RPMFactory(GenericPackageFactory): pass @@ -539,8 +533,9 @@ def create_build_workflow_inputs( # Factory registry _FACTORIES: Dict[PackageType, GenericPackageFactory] = { - PackageType.DEBIAN: DebianFactory(), PackageType.DOCKER: DockerFactory(), + PackageType.DEBIAN: DebianFactory(), + PackageType.RPM: RPMFactory(), PackageType.HOMEBREW: HomebrewFactory(), } diff --git a/src/redis_release/cli.py b/src/redis_release/cli.py index 62ed874..6a068c9 100644 --- a/src/redis_release/cli.py +++ b/src/redis_release/cli.py @@ -147,7 +147,7 @@ def release( help="Only process specific packages (can be specified multiple times)", ), tree_cutoff: int = typer.Option( - 2000, "--tree-cutoff", "-m", help="Max number of ticks to run the tree for" + 5000, "--tree-cutoff", "-m", help="Max number of ticks to run the tree for" ), force_release_type: Optional[List[str]] = typer.Option( None, diff --git a/src/redis_release/state_manager.py b/src/redis_release/state_manager.py index 177e498..c24a381 100644 --- a/src/redis_release/state_manager.py +++ b/src/redis_release/state_manager.py @@ -13,7 +13,6 @@ from redis_release.bht.state import ReleaseState, logger from redis_release.config import Config -from redis_release.state_display import print_state_table from .bht.state import ReleaseState from .models import ReleaseArgs From 515c5619658984f4e07e21bd839dd5dd2cde2432 Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 21:17:30 +0200 Subject: [PATCH 12/13] Use uv for tests --- .github/workflows/run-tests.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index bfba114..a5d2ff3 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -10,13 +10,12 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Install packages + + - name: Install uv run: | - python -m venv venv - . venv/bin/activate - pip install -e .[dev] + curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Run tests run: | - . venv/bin/activate - pytest \ No newline at end of file + uv run pytest \ No newline at end of file From 92347edf078e351a6705525fe4d3ac1b60c54d4b Mon Sep 17 00:00:00 2001 From: Petar Shtuchkin Date: Fri, 21 Nov 2025 21:26:31 +0200 Subject: [PATCH 13/13] Fix run tests --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a5d2ff3..568a10e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,4 +18,4 @@ jobs: - name: Run tests run: | - uv run pytest \ No newline at end of file + uv run --all-extras pytest \ No newline at end of file