diff --git a/config.yaml b/config.yaml index 3e513b5..a7270f8 100644 --- a/config.yaml +++ b/config.yaml @@ -52,3 +52,16 @@ packages: publish_workflow: release_publish.yml publish_timeout_minutes: 10 publish_inputs: {} + snap: + package_type: snap + repo: redis/redis-snap + # homebrew has one fixed release branch: main + #ref: master + ref: release_automation + build_workflow: release_build_and_test.yml + build_timeout_minutes: 60 + 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 862c079..cd96a89 100644 --- a/src/redis_release/bht/behaviours.py +++ b/src/redis_release/bht/behaviours.py @@ -26,16 +26,9 @@ from redis_release.bht.state import reset_model_to_defaults from ..github_client_async import GitHubClientAsync -from ..models import ( - HomebrewChannel, - PackageType, - RedisVersion, - ReleaseType, - WorkflowConclusion, - WorkflowStatus, -) +from ..models import RedisVersion, ReleaseType, WorkflowConclusion, WorkflowStatus from .logging_wrapper import PyTreesLoggerWrapper -from .state import HomebrewMeta, Package, PackageMeta, ReleaseMeta, Workflow +from .state import Package, PackageMeta, ReleaseMeta, Workflow logger = logging.getLogger(__name__) @@ -721,273 +714,6 @@ def update(self) -> Status: return Status.SUCCESS -class DockerWorkflowInputs(ReleaseAction): - """ - Docker uses only release_tag input which is set automatically in TriggerWorkflow - """ - - def __init__( - self, - name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str = "", - ) -> None: - self.workflow = workflow - self.package_meta = package_meta - self.release_meta = release_meta - super().__init__(name=name, log_prefix=log_prefix) - - def update(self) -> Status: - return Status.SUCCESS - - -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: - self.logger.error(f"Failed to parse release tag: {e}") - return - - def update(self) -> Status: - if self.release_meta.tag is None: - logger.error("Release tag is not set") - return Status.FAILURE - - if ( - self.package_meta.homebrew_channel is not None - and self.package_meta.release_type is not None - ): - pass - else: - assert self.release_version is not None - if self.package_meta.release_type is None: - if self.release_version.is_internal: - self.package_meta.release_type = ReleaseType.INTERNAL - else: - if self.release_version.is_ga: - self.package_meta.release_type = ReleaseType.PUBLIC - elif self.release_version.is_rc: - self.package_meta.release_type = ReleaseType.PUBLIC - else: - self.package_meta.release_type = ReleaseType.INTERNAL - - if self.package_meta.homebrew_channel is None: - if self.release_version.is_ga: - self.package_meta.homebrew_channel = HomebrewChannel.STABLE - else: - # RC, internal, or any other version goes to RC channel - 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( - f"Hombrew release_type: {self.package_meta.release_type}, homebrew_channel: {self.package_meta.homebrew_channel}" - ) - - 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.""" - 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") - 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 - - 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}") - 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.""" - 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 - 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" - 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 - 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.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"[{prepend_color}]{log_prepend}{self.feedback_message}[/]" - ) - return Status.SUCCESS - - except Exception as e: - return self.log_exception_and_return_failure(e) - - ### Conditions ### @@ -1138,38 +864,6 @@ def update(self) -> Status: return Status.SUCCESS -class DetectReleaseType(LoggingAction): - def __init__( - 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.package_meta.release_type is not None: - if self.log_once( - "release_type_detected", self.package_meta.ephemeral.log_once_flags - ): - self.logger.info( - 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.package_meta.release_type = ReleaseType.INTERNAL - else: - 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.package_meta.release_type}") - return Status.SUCCESS - - class IsForceRebuild(LoggingAction): def __init__( self, name: str, package_meta: PackageMeta, log_prefix: str = "" @@ -1181,21 +875,3 @@ 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/behaviours_debian.py b/src/redis_release/bht/behaviours_debian.py new file mode 100644 index 0000000..eb4735d --- /dev/null +++ b/src/redis_release/bht/behaviours_debian.py @@ -0,0 +1,115 @@ +from typing import Optional + +from py_trees.common import Status + +from redis_release.bht.behaviours import LoggingAction +from redis_release.bht.state import PackageMeta, ReleaseMeta +from redis_release.models import RedisVersion, ReleaseType + + +class DetectReleaseTypeDebian(LoggingAction): + """Detect release type for Debian packages based on version.""" + + def __init__( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.release_meta = release_meta + self.package_meta = package_meta + self.release_version: Optional[RedisVersion] = None + super().__init__(name=name, log_prefix=log_prefix) + + def initialise(self) -> None: + if self.package_meta.release_type is not None: + return + if self.release_meta.tag is None: + self.logger.error("Release tag is not set") + return + if self.release_meta.tag == "unstable": + return + self.release_version = RedisVersion.parse(self.release_meta.tag) + + def update(self) -> Status: + result: Status = Status.FAILURE + + if self.package_meta.release_type is not None: + result = Status.SUCCESS + self.feedback_message = f"Release type for debian (from state): {self.package_meta.release_type}" + elif self.release_version is not None: + # Debian only publishes GA versions + if self.release_version.is_ga: + self.package_meta.release_type = ReleaseType.PUBLIC + else: + self.package_meta.release_type = ReleaseType.INTERNAL + result = Status.SUCCESS + self.feedback_message = ( + f"Detected release type for debian: {self.package_meta.release_type}" + ) + else: + self.feedback_message = "Failed to detect release type" + result = Status.FAILURE + + if self.log_once( + "release_type_detected", self.package_meta.ephemeral.log_once_flags + ): + if result == Status.SUCCESS: + self.logger.info(f"[green]{self.feedback_message}[/green]") + else: + self.logger.error(f"[red]{self.feedback_message}[/red]") + return result + + +# Conditions + + +class NeedToReleaseDebian(LoggingAction): + """Check if Debian package needs to be released.""" + + def __init__( + self, + name: str, + package_meta: PackageMeta, + 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 + + if self.release_meta.tag == "unstable": + 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 + pass + + def update(self) -> Status: + result: Status = Status.FAILURE + if self.release_meta.tag is None: + self.feedback_message = "Release tag is not set" + result = Status.FAILURE + if self.release_meta.tag == "unstable": + self.feedback_message = "No, skip unstable release for debian" + result = Status.FAILURE + + if self.release_version is not None: + self.feedback_message = "Yes, need to release debian" + result = Status.SUCCESS + + if self.log_once("need_to_release", self.package_meta.ephemeral.log_once_flags): + self.logger.info(self.feedback_message) + return result diff --git a/src/redis_release/bht/behaviours_docker.py b/src/redis_release/bht/behaviours_docker.py new file mode 100644 index 0000000..5582df4 --- /dev/null +++ b/src/redis_release/bht/behaviours_docker.py @@ -0,0 +1,146 @@ +from typing import Optional + +from py_trees.common import Status + +from redis_release.bht.behaviours import LoggingAction, ReleaseAction +from redis_release.bht.state import PackageMeta, ReleaseMeta, Workflow +from redis_release.models import RedisVersion, ReleaseType + + +class DockerWorkflowInputs(ReleaseAction): + """ + Docker uses only release_tag input which is set automatically in TriggerWorkflow + """ + + def __init__( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.workflow = workflow + self.package_meta = package_meta + self.release_meta = release_meta + super().__init__(name=name, log_prefix=log_prefix) + + def update(self) -> Status: + return Status.SUCCESS + + +class DetectReleaseTypeDocker(LoggingAction): + """Detect release type for Docker packages based on version.""" + + def __init__( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.release_meta = release_meta + self.package_meta = package_meta + self.release_version: Optional[RedisVersion] = None + super().__init__(name=name, log_prefix=log_prefix) + + def initialise(self) -> None: + if self.package_meta.release_type is not None: + return + if self.release_meta.tag is None: + self.logger.error("Release tag is not set") + return + if self.release_meta.tag == "unstable": + return + self.release_version = RedisVersion.parse(self.release_meta.tag) + + def update(self) -> Status: + result: Status = Status.FAILURE + + if self.package_meta.release_type is not None: + result = Status.SUCCESS + self.feedback_message = f"Release type for docker (from state): {self.package_meta.release_type}" + elif self.release_version is not None: + if self.release_version.is_internal: + self.package_meta.release_type = ReleaseType.INTERNAL + else: + self.package_meta.release_type = ReleaseType.PUBLIC + result = Status.SUCCESS + self.feedback_message = ( + f"Detected release type for docker: {self.package_meta.release_type}" + ) + else: + self.feedback_message = "Failed to detect release type" + result = Status.FAILURE + + if self.log_once( + "release_type_detected", self.package_meta.ephemeral.log_once_flags + ): + if result == Status.SUCCESS: + self.logger.info(f"[green]{self.feedback_message}[/green]") + else: + self.logger.error(f"[red]{self.feedback_message}[/red]") + return result + + +# Conditions + + +class NeedToReleaseDocker(LoggingAction): + """Check if Docker package needs to be released.""" + + def __init__( + self, + name: str, + package_meta: PackageMeta, + 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 + + if self.release_meta.tag == "unstable": + 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 + pass + + def update(self) -> Status: + result: Status = Status.FAILURE + if self.release_meta.tag is None: + self.feedback_message = "Release tag is not set" + result = Status.FAILURE + if self.release_meta.tag == "unstable": + self.feedback_message = "Skip unstable release for docker" + result = Status.FAILURE + + if self.release_version is not None: + if self.release_version.major < 8: + self.feedback_message = ( + f"Skip release for docker {str(self.release_version)} < 8.0" + ) + result = Status.FAILURE + else: + self.feedback_message = ( + f"Need to release docker version {str(self.release_version)}" + ) + result = Status.SUCCESS + + if self.log_once("need_to_release", self.package_meta.ephemeral.log_once_flags): + color_open = "" if result == Status.SUCCESS else "[yellow]" + color_close = "" if result == Status.SUCCESS else "[/]" + self.logger.info(f"{color_open}{self.feedback_message}{color_close}") + return result diff --git a/src/redis_release/bht/behaviours_homebrew.py b/src/redis_release/bht/behaviours_homebrew.py new file mode 100644 index 0000000..c24c426 --- /dev/null +++ b/src/redis_release/bht/behaviours_homebrew.py @@ -0,0 +1,320 @@ +import asyncio +import re +from typing import Optional + +from py_trees.common import Status + +from redis_release.bht.behaviours import LoggingAction, ReleaseAction, logger +from redis_release.bht.state import HomebrewMeta, ReleaseMeta, Workflow +from redis_release.github_client_async import GitHubClientAsync +from redis_release.models import HomebrewChannel, RedisVersion, ReleaseType + + +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 + if self.release_meta.tag == "unstable": + return + self.feedback_message = "" + 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: + logger.error("Release tag is not set") + return Status.FAILURE + + if ( + self.package_meta.homebrew_channel is not None + and self.package_meta.release_type is not None + ): + return Status.SUCCESS + else: + if self.release_meta.tag == "unstable": + self.feedback_message = "Skip unstable release for Homebrew" + if self.log_once( + "homebrew_channel_detected", + self.package_meta.ephemeral.log_once_flags, + ): + self.logger.info(self.feedback_message) + return Status.SUCCESS + + assert self.release_version is not None + if self.package_meta.release_type is None: + if self.release_version.is_internal: + self.package_meta.release_type = ReleaseType.INTERNAL + else: + if self.release_version.is_ga: + self.package_meta.release_type = ReleaseType.PUBLIC + elif self.release_version.is_rc: + self.package_meta.release_type = ReleaseType.PUBLIC + else: + self.package_meta.release_type = ReleaseType.INTERNAL + + if self.package_meta.homebrew_channel is None: + if self.release_version.is_ga: + self.package_meta.homebrew_channel = HomebrewChannel.STABLE + else: + # RC, internal, or any other version goes to RC channel + 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( + f"Hombrew release_type: {self.package_meta.release_type}, homebrew_channel: {self.package_meta.homebrew_channel}" + ) + + 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.""" + if self.package_meta.ephemeral.is_version_acceptable is not None: + return + + if self.release_meta.tag == "unstable": + self.package_meta.ephemeral.is_version_acceptable = False + # we need to set remote version to not None as it is a sign of successful classify step + self.package_meta.remote_version = "unstable" + 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") + 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 + + 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}") + 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.""" + 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 + 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" + 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 + 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.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"[{prepend_color}]{log_prepend}{self.feedback_message}[/]" + ) + return Status.SUCCESS + + except Exception as e: + return self.log_exception_and_return_failure(e) + + +class DetectReleaseTypeHomebrew(LoggingAction): + """Check that release_type is set for Homebrew packages. + + Homebrew packages should have release_type set by DetectHombrewReleaseAndChannel. + This behavior just validates that it's set and fails if not. + """ + + 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.release_type is not None: + self.feedback_message = f"Release type: {self.package_meta.release_type}" + return Status.SUCCESS + else: + self.feedback_message = "Release type is not set" + self.logger.error("Release type is not set for Homebrew package") + return Status.FAILURE + + +# Conditions + + +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/behaviours_rpm.py b/src/redis_release/bht/behaviours_rpm.py new file mode 100644 index 0000000..ed5b559 --- /dev/null +++ b/src/redis_release/bht/behaviours_rpm.py @@ -0,0 +1,130 @@ +from typing import Optional + +from py_trees.common import Status + +from redis_release.bht.behaviours import LoggingAction +from redis_release.bht.state import PackageMeta, ReleaseMeta +from redis_release.models import RedisVersion, ReleaseType + + +class DetectReleaseTypeRPM(LoggingAction): + """Detect release type for RPM packages based on version.""" + + def __init__( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str = "", + ) -> None: + self.release_meta = release_meta + self.package_meta = package_meta + self.release_version: Optional[RedisVersion] = None + super().__init__(name=name, log_prefix=log_prefix) + + def initialise(self) -> None: + if self.package_meta.release_type is not None: + return + if self.release_meta.tag is None: + self.logger.error("Release tag is not set") + return + if self.release_meta.tag == "unstable": + return + self.release_version = RedisVersion.parse(self.release_meta.tag) + + def update(self) -> Status: + result: Status = Status.FAILURE + + if self.package_meta.release_type is not None: + result = Status.SUCCESS + self.feedback_message = ( + f"Release type for RPM (from state): {self.package_meta.release_type}" + ) + elif self.release_version is not None: + # RPM only publishes GA versions + if self.release_version.is_ga: + self.package_meta.release_type = ReleaseType.PUBLIC + else: + self.package_meta.release_type = ReleaseType.INTERNAL + result = Status.SUCCESS + self.feedback_message = ( + f"Detected release type for RPM: {self.package_meta.release_type}" + ) + else: + self.feedback_message = "Failed to detect release type" + result = Status.FAILURE + + if self.log_once( + "release_type_detected", self.package_meta.ephemeral.log_once_flags + ): + if result == Status.SUCCESS: + self.logger.info(f"[green]{self.feedback_message}[/green]") + else: + self.logger.error(f"[red]{self.feedback_message}[/red]") + return result + + +# Conditions + + +class NeedToReleaseRPM(LoggingAction): + """Check if RPM package needs to be released.""" + + def __init__( + self, + name: str, + package_meta: PackageMeta, + 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 + + if self.release_meta.tag == "unstable": + 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 + pass + + def update(self) -> Status: + result: Status = Status.FAILURE + if self.release_meta.tag is None: + self.feedback_message = "Release tag is not set" + result = Status.FAILURE + if self.release_meta.tag == "unstable": + self.feedback_message = "Skip unstable release for RPM" + result = Status.FAILURE + + if self.release_version is not None: + if self.release_version.major < 8: + self.feedback_message = ( + f"Skip release for RPM {str(self.release_version)} < 8.0" + ) + result = Status.FAILURE + else: + self.feedback_message = ( + f"Need to release RPM version {str(self.release_version)}" + ) + result = Status.SUCCESS + else: + self.feedback_message = "Failed to parse release version" + result = Status.FAILURE + + if self.log_once("need_to_release", self.package_meta.ephemeral.log_once_flags): + color_open = "" if result == Status.SUCCESS else "[yellow]" + color_close = "" if result == Status.SUCCESS else "[/]" + self.logger.info(f"{color_open}{self.feedback_message}{color_close}") + return result diff --git a/src/redis_release/bht/behaviours_snap.py b/src/redis_release/bht/behaviours_snap.py new file mode 100644 index 0000000..6b17e30 --- /dev/null +++ b/src/redis_release/bht/behaviours_snap.py @@ -0,0 +1,328 @@ +import asyncio +import json +from typing import Optional + +from py_trees.common import Status + +from redis_release.bht.behaviours import LoggingAction, ReleaseAction, logger +from redis_release.bht.state import ReleaseMeta, SnapMeta, Workflow +from redis_release.github_client_async import GitHubClientAsync +from redis_release.models import RedisVersion, ReleaseType, SnapRiskLevel + + +class SnapWorkflowInputs(ReleaseAction): + def __init__( + self, + name: str, + workflow: Workflow, + package_meta: SnapMeta, + 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} - snap", 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.snap_risk_level is not None: + self.workflow.inputs["risk_level"] = self.package_meta.snap_risk_level.value + return Status.SUCCESS + + +class DetectSnapReleaseAndRiskLevel(ReleaseAction): + """Detect Snap release type and risk level based on release version. + + Logic: + - is_internal: sets release_type to INTERNAL, risk_level to CANDIDATE + - is_ga: sets release_type to PUBLIC, risk_level to STABLE + - is_rc: sets release_type to PUBLIC, risk_level to CANDIDATE + """ + + def __init__( + self, + name: str, + package_meta: SnapMeta, + 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 = "" + if self.release_meta.tag == "unstable": + 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: + logger.error("Release tag is not set") + return Status.FAILURE + + if ( + self.package_meta.snap_risk_level is not None + and self.package_meta.release_type is not None + ): + return Status.SUCCESS + else: + if self.release_meta.tag == "unstable": + self.package_meta.release_type = ReleaseType.PUBLIC + self.package_meta.snap_risk_level = SnapRiskLevel.EDGE + else: + assert self.release_version is not None + if self.package_meta.release_type is None: + if self.release_version.is_internal: + self.package_meta.release_type = ReleaseType.INTERNAL + self.package_meta.snap_risk_level = SnapRiskLevel.CANDIDATE + else: + self.package_meta.release_type = ReleaseType.PUBLIC + + if self.package_meta.snap_risk_level is None: + if self.release_version.is_ga: + self.package_meta.snap_risk_level = SnapRiskLevel.STABLE + else: + # other versions go to CANDIDATE + self.package_meta.snap_risk_level = SnapRiskLevel.CANDIDATE + + self.feedback_message = f"release_type: {self.package_meta.release_type.value}, snap_risk_level: {self.package_meta.snap_risk_level.value}" + + if self.log_once( + "snap_risk_level_detected", self.package_meta.ephemeral.log_once_flags + ): + self.logger.info( + f"Snap release_type: {self.package_meta.release_type}, snap_risk_level: {self.package_meta.snap_risk_level}" + ) + + return Status.SUCCESS + + +class ClassifySnapVersion(ReleaseAction): + """Classify Snap version by downloading and parsing .redis_versions.json file. + + This behavior downloads the .redis_versions.json file from the snap repository, + extracts the version for the appropriate risk level (stable/candidate/edge), + and compares it with the release tag version to determine if the version is acceptable. + + The version is acceptable if: release_version >= remote_version for the risk level. + """ + + def __init__( + self, + name: str, + package_meta: SnapMeta, + 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.remote_version: Optional[RedisVersion] = None + super().__init__(name=name, log_prefix=log_prefix) + + 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 snap_risk_level is set + if self.package_meta.snap_risk_level is None: + self.logger.error("Snap risk level 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 + + if self.package_meta.release_type is None: + self.logger.error("Package release type is not set") + return + + if self.package_meta.snap_risk_level is None: + self.logger.error("Snap risk level is not set") + return + + if self.release_meta.tag == "unstable": + self.package_meta.ephemeral.is_version_acceptable = True + # we need to set remote version to not None as it is a sign of successful classify step + self.package_meta.remote_version = "unstable" + 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 + + # Download .redis_versions.json file + versions_file = ".redis_versions.json" + self.logger.debug( + f"Downloading versions file: {versions_file} from {self.package_meta.repo}@{self.package_meta.ref}" + ) + + # Start async task to download the versions file from package repo and ref + self.task = asyncio.create_task( + self.github_client.download_file( + self.package_meta.repo, versions_file, self.package_meta.ref + ) + ) + + def update(self) -> Status: + """Process downloaded versions file and classify version.""" + 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 + if not self.task.done(): + return Status.RUNNING + + # Get the downloaded content + versions_content = self.task.result() + if versions_content is None: + self.logger.error("Failed to download .redis_versions.json file") + return Status.FAILURE + + # Parse JSON content + try: + versions_data = json.loads(versions_content) + except json.JSONDecodeError as e: + self.logger.error(f"Failed to parse .redis_versions.json: {e}") + return Status.FAILURE + + # Get version for the current risk level + assert self.package_meta.snap_risk_level is not None + risk_level_key = self.package_meta.snap_risk_level.value + if risk_level_key not in versions_data: + self.logger.error( + f"Risk level '{risk_level_key}' not found in .redis_versions.json" + ) + return Status.FAILURE + + version_str = versions_data[risk_level_key] + self.logger.debug( + f"Found version for {risk_level_key} risk level: {version_str}" + ) + + try: + self.remote_version = RedisVersion.parse(version_str) + self.logger.info( + f"Remote version: {self.remote_version}, Release version: {self.release_version}" + ) + except ValueError as e: + self.logger.error( + f"Failed to parse remote version '{version_str}': {e}" + ) + return Status.FAILURE + + # Compare versions: release version >= remote version means acceptable + assert self.release_version is not None + self.package_meta.remote_version = str(self.remote_version) + log_prepend = "" + prepend_color = "green" + if self.release_version >= self.remote_version: + self.package_meta.ephemeral.is_version_acceptable = True + self.feedback_message = ( + f"release {self.release_version} >= remote {self.remote_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} < remote {self.remote_version}" + ) + if self.log_once( + "snap_version_classified", + self.package_meta.ephemeral.log_once_flags, + ): + self.logger.info( + f"[{prepend_color}]{log_prepend}{self.feedback_message}[/]" + ) + return Status.SUCCESS + + except Exception as e: + return self.log_exception_and_return_failure(e) + + +class DetectReleaseTypeSnap(LoggingAction): + """Check that release_type is set for Snap packages. + + Snap packages should have release_type set by DetectSnapReleaseAndRiskLevel. + This behavior just validates that it's set and fails if not. + """ + + def __init__( + self, + name: str, + package_meta: SnapMeta, + 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.release_type is not None: + self.feedback_message = f"Release type: {self.package_meta.release_type}" + return Status.SUCCESS + else: + self.feedback_message = "Release type is not set" + self.logger.error("Release type is not set for Snap package") + return Status.FAILURE + + +# Conditions + + +class NeedToReleaseSnap(LoggingAction): + def __init__( + self, + name: str, + package_meta: SnapMeta, + 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 327c4dc..d9c8aad 100644 --- a/src/redis_release/bht/composites.py +++ b/src/redis_release/bht/composites.py @@ -21,7 +21,6 @@ from ..github_client_async import GitHubClientAsync from .behaviours import ( - ClassifyHomebrewVersion, ExtractArtifactResult, GetWorkflowArtifactsList, IdentifyTargetRef, @@ -32,8 +31,10 @@ ) from .behaviours import TriggerWorkflow as TriggerWorkflow from .behaviours import UpdateWorkflowStatusUntilCompletion +from .behaviours_homebrew import ClassifyHomebrewVersion +from .behaviours_snap import ClassifySnapVersion from .decorators import ConditionGuard, FlagGuard, StatusFlagGuard -from .state import HomebrewMeta, Package, PackageMeta, ReleaseMeta, Workflow +from .state import HomebrewMeta, Package, PackageMeta, ReleaseMeta, SnapMeta, Workflow class ParallelBarrier(Composite): @@ -60,8 +61,10 @@ class ParallelBarrier(Composite): def __init__( self, name: str, + memory: bool = True, children: Optional[TypingSequence[Behaviour]] = None, ): + self.memory = memory super().__init__(name, children) def tick(self) -> Iterator[Behaviour]: @@ -83,7 +86,7 @@ def tick(self) -> Iterator[Behaviour]: # Tick all children, skipping those that have already converged for child in self.children: # Skip children that have already converged (synchronized mode) - if child.status in [Status.SUCCESS, Status.FAILURE]: + if self.memory and child.status in [Status.SUCCESS, Status.FAILURE]: continue # Tick the child for node in child.tick(): @@ -418,3 +421,27 @@ def __init__( "classify_remote_versions", log_prefix=log_prefix, ) + + +class ClassifySnapVersionGuarded(StatusFlagGuard): + def __init__( + self, + name: str, + package_meta: SnapMeta, + release_meta: ReleaseMeta, + github_client: GitHubClientAsync, + log_prefix: str = "", + ) -> None: + super().__init__( + None if name == "" else name, + ClassifySnapVersion( + "Classify Snap 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/ppas.py b/src/redis_release/bht/ppas.py index c50c65f..1ff8913 100644 --- a/src/redis_release/bht/ppas.py +++ b/src/redis_release/bht/ppas.py @@ -1,6 +1,8 @@ """ Here we define PPAs (Postcondition-Precondition-Action) composites to be used in backchaining. +More specific PPAs are defined directly in the tree factory files. + See backchain.py for more details on backchaining. Chains are formed and latched in `tree_factory.py` @@ -15,7 +17,6 @@ from .backchain import create_PPA from .behaviours import ( AttachReleaseHandleToPublishWorkflow, - DetectReleaseType, HasWorkflowArtifacts, HasWorkflowResult, IsTargetRefIdentified, @@ -136,19 +137,6 @@ 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", package_meta, release_meta, log_prefix=log_prefix - ), - ) - - def create_download_artifacts_ppa( workflow: Workflow, package_meta: PackageMeta, diff --git a/src/redis_release/bht/tree.py b/src/redis_release/bht/tree.py index b501925..b958dd8 100644 --- a/src/redis_release/bht/tree.py +++ b/src/redis_release/bht/tree.py @@ -1,6 +1,6 @@ """ -This module contains tree initialization, larger branches creation -and utility functions to run or inspect the tree. +This module contains tree initialization and utility functions to run or inspect +the tree. """ import asyncio @@ -142,6 +142,7 @@ def initialize_tree_and_state( args.slack_channel_id, args.slack_thread_ts, args.slack_reply_broadcast, + args.slack_format, ) # Capture the non-None printer in the closure printer = slack_printer @@ -192,6 +193,7 @@ def create_root_node( root = ParallelBarrier( "Redis Release", + memory=False, children=[], ) for package_name, package in state.packages.items(): @@ -342,7 +344,9 @@ def create_by_name(self, name: str) -> Union[Selector, Sequence, Behaviour]: package, release_meta, package, github_client, "" ) elif name == "package_release_branch": - return get_factory(self.package_type).create_package_release_tree_branch( + return get_factory( + self.package_type + ).create_package_release_execute_workflows_tree_branch( package, release_meta, package, github_client, "" ) elif name == "package_release_goal_branch": diff --git a/src/redis_release/bht/tree_factory.py b/src/redis_release/bht/tree_factory.py index 3ef6c1b..fbffced 100644 --- a/src/redis_release/bht/tree_factory.py +++ b/src/redis_release/bht/tree_factory.py @@ -3,557 +3,27 @@ """ import logging -from abc import ABC -from typing import Dict, List, Optional, Union, cast +from typing import Dict, Optional, Union -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 redis_release.bht.tree_factory_debian import DebianFactory +from redis_release.bht.tree_factory_docker import DockerFactory +from redis_release.bht.tree_factory_generic import GenericPackageFactory +from redis_release.bht.tree_factory_homebrew import HomebrewFactory +from redis_release.bht.tree_factory_rpm import RPMFactory +from redis_release.bht.tree_factory_snap import SnapFactory -from ..github_client_async import GitHubClientAsync from ..models import PackageType -from .backchain import create_PPA, latch_chains -from .behaviours import ( - DetectHombrewReleaseAndChannel, - DockerWorkflowInputs, - GenericWorkflowInputs, - HomewbrewWorkflowInputs, - NeedToPublishRelease, - NeedToReleaseHomebrew, -) -from .composites import ( - ClassifyHomebrewVersionGuarded, - 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 GenericPackageFactory(ABC): - """Default factory for packages without specific customizations.""" - - 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]: - package_release = self.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], - ) - - 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 - ) - - 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) - identify_target_ref = create_identify_target_ref_ppa( - package_meta, - release_meta, - github_client, - log_prefix, - ) - detect_release_type = create_detect_release_type_ppa( - package_meta, - 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_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 DockerFactory(GenericPackageFactory): - """Factory for Docker packages.""" - - def create_build_workflow_inputs( - self, - name: str, - workflow: Workflow, - package_meta: PackageMeta, - release_meta: ReleaseMeta, - log_prefix: str, - ) -> Behaviour: - return DockerWorkflowInputs( - 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 DockerWorkflowInputs( - name, workflow, package_meta, release_meta, log_prefix=log_prefix - ) - - -class DebianFactory(GenericPackageFactory): - pass - - -class RPMFactory(GenericPackageFactory): - pass - - -class HomebrewFactory(GenericPackageFactory): - 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]: - 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"Release Workflows {package_name} Goal", - memory=False, - children=[Inverter("Not", need_to_release), package_release], - ) - reset_package_state = ResetPackageStateGuarded( - "", - package, - default_package, - log_prefix=package_name, - ) - return Sequence( - f"Release {package_name}", - memory=False, - children=[ - reset_package_state, - DetectHombrewReleaseAndChannel( - "Detect Homebrew Channel", - cast(HomebrewMeta, package.meta), - release_meta, - log_prefix=package_name, - ), - ClassifyHomebrewVersionGuarded( - "", - cast(HomebrewMeta, package.meta), - release_meta, - github_client, - log_prefix=package_name, - ), - release_goal, - ], - ) - - 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, - 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 - - 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, - ) - def create_publish_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, GenericPackageFactory] = { PackageType.DOCKER: DockerFactory(), PackageType.DEBIAN: DebianFactory(), PackageType.RPM: RPMFactory(), PackageType.HOMEBREW: HomebrewFactory(), + PackageType.SNAP: SnapFactory(), } _DEFAULT_FACTORY = GenericPackageFactory() diff --git a/src/redis_release/bht/tree_factory_debian.py b/src/redis_release/bht/tree_factory_debian.py new file mode 100644 index 0000000..925c684 --- /dev/null +++ b/src/redis_release/bht/tree_factory_debian.py @@ -0,0 +1,32 @@ +from py_trees.behaviour import Behaviour + +from redis_release.bht.behaviours_debian import ( + DetectReleaseTypeDebian, + NeedToReleaseDebian, +) +from redis_release.bht.state import PackageMeta, ReleaseMeta +from redis_release.bht.tree_factory_generic import GenericPackageFactory + + +class DebianFactory(GenericPackageFactory): + def create_need_to_release_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return NeedToReleaseDebian( + name, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_detect_release_type_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return DetectReleaseTypeDebian( + name, package_meta, release_meta, log_prefix=log_prefix + ) diff --git a/src/redis_release/bht/tree_factory_docker.py b/src/redis_release/bht/tree_factory_docker.py new file mode 100644 index 0000000..0864d09 --- /dev/null +++ b/src/redis_release/bht/tree_factory_docker.py @@ -0,0 +1,59 @@ +from py_trees.behaviour import Behaviour + +from redis_release.bht.behaviours_docker import ( + DetectReleaseTypeDocker, + DockerWorkflowInputs, + NeedToReleaseDocker, +) +from redis_release.bht.state import PackageMeta, ReleaseMeta, Workflow +from redis_release.bht.tree_factory_generic import GenericPackageFactory + + +class DockerFactory(GenericPackageFactory): + """Factory for Docker packages.""" + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return DockerWorkflowInputs( + 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 DockerWorkflowInputs( + name, workflow, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_need_to_release_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return NeedToReleaseDocker( + name, package_meta, release_meta, log_prefix=log_prefix + ) + + def create_detect_release_type_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return DetectReleaseTypeDocker( + name, package_meta, release_meta, log_prefix=log_prefix + ) diff --git a/src/redis_release/bht/tree_factory_generic.py b/src/redis_release/bht/tree_factory_generic.py new file mode 100644 index 0000000..a62d667 --- /dev/null +++ b/src/redis_release/bht/tree_factory_generic.py @@ -0,0 +1,450 @@ +from abc import ABC +from typing import List, Optional, Union + +from py_trees.behaviour import Behaviour +from py_trees.behaviours import Failure as AlwaysFailure +from py_trees.behaviours import Success as AlwaysSuccess +from py_trees.composites import Selector, Sequence +from py_trees.decorators import Inverter + +from redis_release.bht.backchain import create_PPA, latch_chains +from redis_release.bht.behaviours import GenericWorkflowInputs, NeedToPublishRelease +from redis_release.bht.composites import ( + ResetPackageStateGuarded, + RestartPackageGuarded, + RestartWorkflowGuarded, +) +from redis_release.bht.ppas import ( + create_attach_release_handle_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 redis_release.bht.state import Package, PackageMeta, ReleaseMeta, Workflow +from redis_release.bht.tree_factory_protocol import GenericPackageFactoryProtocol +from redis_release.github_client_async import GitHubClientAsync + + +class GenericPackageFactory(ABC): + """Default factory for packages without specific customizations.""" + + 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]: + package_release = self.create_package_release_execute_workflows_tree_branch( + package, release_meta, default_package, github_client, package_name + ) + return Selector( + f"Package Release {package_name} Goal", + memory=False, + children=[ + Inverter( + "Not", + self.create_need_to_release_behaviour( + f"Need To Release {package_name}?", + package.meta, + release_meta, + log_prefix=package_name, + ), + ), + package_release, + ], + ) + + 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 + ) + + 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) + identify_target_ref = create_identify_target_ref_ppa( + package_meta, + release_meta, + github_client, + log_prefix, + ) + + detect_release_type = create_PPA( + "Detect Release Type", + self.create_detect_release_type_behaviour( + f"Detect Release Type", + package_meta, + release_meta, + log_prefix=log_prefix, + ), + ) + + latch_chains( + workflow_complete, + find_workflow_by_uud, + trigger_workflow, + detect_release_type, + identify_target_ref, + ) + return workflow_complete + + def create_package_release_execute_workflows_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 + + def create_need_to_release_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + """Create a behaviour that checks if the package needs to be released. + + Default implementation always returns SUCCESS (always release). + Override in subclasses for package-specific logic. + """ + return AlwaysSuccess(name) + + def create_detect_release_type_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + raise NotImplementedError + + +class PackageWithValidation: + """ + Mixin class for packages that have validation step before release, e.g. Homebrew and Snap + """ + + def create_package_release_execute_workflows_tree_branch( + self: GenericPackageFactoryProtocol, + 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: GenericPackageFactoryProtocol, + 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 diff --git a/src/redis_release/bht/tree_factory_homebrew.py b/src/redis_release/bht/tree_factory_homebrew.py new file mode 100644 index 0000000..c154281 --- /dev/null +++ b/src/redis_release/bht/tree_factory_homebrew.py @@ -0,0 +1,129 @@ +from typing import Union, cast + +from py_trees.behaviour import Behaviour +from py_trees.composites import Selector, Sequence +from py_trees.decorators import Inverter + +from redis_release.bht.behaviours_homebrew import ( + DetectHombrewReleaseAndChannel, + DetectReleaseTypeHomebrew, + HomewbrewWorkflowInputs, + NeedToReleaseHomebrew, +) +from redis_release.bht.composites import ( + ClassifyHomebrewVersionGuarded, + ResetPackageStateGuarded, +) +from redis_release.bht.state import ( + HomebrewMeta, + Package, + PackageMeta, + ReleaseMeta, + Workflow, +) +from redis_release.bht.tree_factory_generic import ( + GenericPackageFactory, + PackageWithValidation, +) +from redis_release.github_client_async import GitHubClientAsync + + +class HomebrewFactory(GenericPackageFactory, PackageWithValidation): + 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]: + package_release = self.create_package_release_execute_workflows_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"Release Workflows {package_name} Goal", + memory=False, + children=[Inverter("Not", need_to_release), package_release], + ) + reset_package_state = ResetPackageStateGuarded( + "", + package, + default_package, + log_prefix=package_name, + ) + return Sequence( + f"Release {package_name}", + memory=False, + children=[ + reset_package_state, + DetectHombrewReleaseAndChannel( + "Detect Homebrew Channel", + cast(HomebrewMeta, package.meta), + release_meta, + log_prefix=package_name, + ), + ClassifyHomebrewVersionGuarded( + "", + cast(HomebrewMeta, package.meta), + release_meta, + github_client, + log_prefix=package_name, + ), + release_goal, + ], + ) + + 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, + ) + + def create_publish_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, + ) + + def create_detect_release_type_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + """Homebrew packages check that release_type is already set.""" + return DetectReleaseTypeHomebrew( + name, + cast(HomebrewMeta, package_meta), + release_meta, + log_prefix=log_prefix, + ) diff --git a/src/redis_release/bht/tree_factory_protocol.py b/src/redis_release/bht/tree_factory_protocol.py new file mode 100644 index 0000000..486e07c --- /dev/null +++ b/src/redis_release/bht/tree_factory_protocol.py @@ -0,0 +1,113 @@ +from typing import List, Optional, Protocol, Union + +from py_trees.behaviour import Behaviour +from py_trees.composites import Selector, Sequence + +from redis_release.bht.state import Package, PackageMeta, ReleaseMeta, Workflow +from redis_release.github_client_async import GitHubClientAsync + + +class GenericPackageFactoryProtocol(Protocol): + """Protocol defining the interface for package-specific tree factories.""" + + 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]: ... + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: ... + + def create_publish_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: ... + + 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]: ... + + def create_package_release_execute_workflows_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: ... + + def create_build_workflow_tree_branch( + self, + package: Package, + release_meta: ReleaseMeta, + default_package: Package, + github_client: GitHubClientAsync, + package_name: str, + ) -> Union[Selector, Sequence]: ... + + 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]: ... + + 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]: ... + + def create_extract_result_tree_branch( + self, + artifact_name: str, + workflow: Workflow, + package_meta: PackageMeta, + github_client: GitHubClientAsync, + log_prefix: str, + ) -> Union[Selector, Sequence]: ... + + def create_need_to_release_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: ... + + def create_detect_release_type_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: ... diff --git a/src/redis_release/bht/tree_factory_rpm.py b/src/redis_release/bht/tree_factory_rpm.py new file mode 100644 index 0000000..87e92d4 --- /dev/null +++ b/src/redis_release/bht/tree_factory_rpm.py @@ -0,0 +1,27 @@ +from py_trees.behaviour import Behaviour + +from redis_release.bht.behaviours_rpm import DetectReleaseTypeRPM, NeedToReleaseRPM +from redis_release.bht.state import PackageMeta, ReleaseMeta +from redis_release.bht.tree_factory_generic import GenericPackageFactory + + +class RPMFactory(GenericPackageFactory): + def create_need_to_release_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return NeedToReleaseRPM(name, package_meta, release_meta, log_prefix=log_prefix) + + def create_detect_release_type_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + return DetectReleaseTypeRPM( + name, package_meta, release_meta, log_prefix=log_prefix + ) diff --git a/src/redis_release/bht/tree_factory_snap.py b/src/redis_release/bht/tree_factory_snap.py new file mode 100644 index 0000000..d6f496b --- /dev/null +++ b/src/redis_release/bht/tree_factory_snap.py @@ -0,0 +1,129 @@ +from typing import Union, cast + +from py_trees.behaviour import Behaviour +from py_trees.composites import Selector, Sequence +from py_trees.decorators import Inverter + +from redis_release.bht.behaviours_snap import ( + DetectReleaseTypeSnap, + DetectSnapReleaseAndRiskLevel, + NeedToReleaseSnap, + SnapWorkflowInputs, +) +from redis_release.bht.composites import ( + ClassifySnapVersionGuarded, + ResetPackageStateGuarded, +) +from redis_release.bht.state import ( + Package, + PackageMeta, + ReleaseMeta, + SnapMeta, + Workflow, +) +from redis_release.bht.tree_factory_generic import ( + GenericPackageFactory, + PackageWithValidation, +) +from redis_release.github_client_async import GitHubClientAsync + + +class SnapFactory(GenericPackageFactory, PackageWithValidation): + 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]: + package_release = self.create_package_release_execute_workflows_tree_branch( + package, release_meta, default_package, github_client, package_name + ) + need_to_release = NeedToReleaseSnap( + "Need To Release?", + cast(SnapMeta, package.meta), + release_meta, + log_prefix=package_name, + ) + release_goal = Selector( + f"Release Workflows {package_name} Goal", + memory=False, + children=[Inverter("Not", need_to_release), package_release], + ) + reset_package_state = ResetPackageStateGuarded( + "", + package, + default_package, + log_prefix=package_name, + ) + return Sequence( + f"Release {package_name}", + memory=False, + children=[ + reset_package_state, + DetectSnapReleaseAndRiskLevel( + "Detect Snap Release and Risk Level", + cast(SnapMeta, package.meta), + release_meta, + log_prefix=package_name, + ), + ClassifySnapVersionGuarded( + "", + cast(SnapMeta, package.meta), + release_meta, + github_client, + log_prefix=package_name, + ), + release_goal, + ], + ) + + def create_build_workflow_inputs( + self, + name: str, + workflow: Workflow, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + + return SnapWorkflowInputs( + name, + workflow, + cast(SnapMeta, 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 SnapWorkflowInputs( + name, + workflow, + cast(SnapMeta, package_meta), + release_meta, + log_prefix=log_prefix, + ) + + def create_detect_release_type_behaviour( + self, + name: str, + package_meta: PackageMeta, + release_meta: ReleaseMeta, + log_prefix: str, + ) -> Behaviour: + """Snap packages check that release_type is already set.""" + return DetectReleaseTypeSnap( + name, + cast(SnapMeta, package_meta), + release_meta, + log_prefix=log_prefix, + ) diff --git a/src/redis_release/cli.py b/src/redis_release/cli.py index 6a068c9..69b111e 100644 --- a/src/redis_release/cli.py +++ b/src/redis_release/cli.py @@ -271,6 +271,11 @@ def slack_bot( "--authorized-user", help="User ID authorized to run releases (can be specified multiple times). If not specified, all users are authorized", ), + slack_format: str = typer.Option( + "default", + "--slack-format", + help="Slack message format: 'default' shows all steps, 'one-step' shows only the last step", + ), ) -> None: """Run Slack bot that listens for status requests. @@ -285,20 +290,33 @@ def slack_bot( Requires Socket Mode to be enabled in your Slack app configuration. """ + from redis_release.models import SlackFormat from redis_release.slack_bot import run_bot setup_logging() config_path = config_file or "config.yaml" + # Parse slack_format + try: + slack_format_enum = SlackFormat(slack_format) + except ValueError: + logger.error( + f"Invalid slack format: {slack_format}. Must be 'default' or 'one-step'" + ) + raise typer.BadParameter( + f"Invalid slack format: {slack_format}. Must be 'default' or 'one-step'" + ) + logger.info("Starting Slack bot...") asyncio.run( run_bot( - config_path, - slack_bot_token, - slack_app_token, - reply_in_thread, - broadcast_to_channel, - authorized_users, + config_path=config_path, + slack_bot_token=slack_bot_token, + slack_app_token=slack_app_token, + reply_in_thread=reply_in_thread, + broadcast_to_channel=broadcast_to_channel, + authorized_users=authorized_users, + slack_format=slack_format_enum, ) ) diff --git a/src/redis_release/models.py b/src/redis_release/models.py index de7975c..4e64029 100644 --- a/src/redis_release/models.py +++ b/src/redis_release/models.py @@ -60,6 +60,13 @@ class ReleaseType(str, Enum): INTERNAL = "internal" +class SlackFormat(str, Enum): + """Slack message format enumeration.""" + + DEFAULT = "default" + ONE_STEP = "one-step" + + class WorkflowStatus(str, Enum): """Workflow status enumeration.""" @@ -241,3 +248,4 @@ class ReleaseArgs(BaseModel): slack_channel_id: Optional[str] = None slack_thread_ts: Optional[str] = None slack_reply_broadcast: bool = False + slack_format: SlackFormat = SlackFormat.DEFAULT diff --git a/src/redis_release/slack_bot.py b/src/redis_release/slack_bot.py index bbdb770..f492240 100644 --- a/src/redis_release/slack_bot.py +++ b/src/redis_release/slack_bot.py @@ -13,7 +13,7 @@ from redis_release.bht.tree import async_tick_tock, initialize_tree_and_state from redis_release.config import Config, load_config -from redis_release.models import ReleaseArgs +from redis_release.models import ReleaseArgs, SlackFormat from redis_release.state_manager import S3StateStorage, StateManager from redis_release.state_slack import init_slack_printer @@ -34,6 +34,7 @@ def __init__( reply_in_thread: bool = True, broadcast_to_channel: bool = False, authorized_users: Optional[List[str]] = None, + slack_format: SlackFormat = SlackFormat.DEFAULT, ): """Initialize the bot. @@ -44,11 +45,13 @@ def __init__( reply_in_thread: If True, reply in thread. If False, reply in main channel broadcast_to_channel: If True and reply_in_thread is True, also show in main channel authorized_users: List of user IDs authorized to run releases. If None, all users are authorized + slack_format: Slack message format (default or one-step) """ self.config = config self.reply_in_thread = reply_in_thread self.broadcast_to_channel = broadcast_to_channel self.authorized_users = authorized_users or [] + self.slack_format = slack_format # Get tokens from args or environment bot_token = slack_bot_token or os.environ.get("SLACK_BOT_TOKEN") @@ -261,6 +264,7 @@ def run_release_in_thread() -> None: slack_channel_id=channel, slack_thread_ts=thread_ts if self.reply_in_thread else None, slack_reply_broadcast=reply_broadcast, + slack_format=self.slack_format, ) # Run the release @@ -355,6 +359,7 @@ async def _post_status( slack_channel_id=channel, thread_ts=thread_ts if self.reply_in_thread else None, reply_broadcast=self.broadcast_to_channel, + slack_format=self.slack_format, ) printer.update_message(state) @@ -391,6 +396,7 @@ async def run_bot( reply_in_thread: bool = True, broadcast_to_channel: bool = False, authorized_users: Optional[List[str]] = None, + slack_format: SlackFormat = SlackFormat.DEFAULT, ) -> None: """Run the Slack bot. @@ -401,6 +407,7 @@ async def run_bot( reply_in_thread: If True, reply in thread. If False, reply in main channel broadcast_to_channel: If True and reply_in_thread is True, also show in main channel authorized_users: List of user IDs authorized to run releases. If None, all users are authorized + slack_format: Slack message format (default or one-step) """ # Load config config = load_config(config_path) @@ -413,6 +420,7 @@ async def run_bot( reply_in_thread=reply_in_thread, broadcast_to_channel=broadcast_to_channel, authorized_users=authorized_users, + slack_format=slack_format, ) await bot.start() diff --git a/src/redis_release/state_slack.py b/src/redis_release/state_slack.py index 4286c6a..80f545b 100644 --- a/src/redis_release/state_slack.py +++ b/src/redis_release/state_slack.py @@ -9,6 +9,7 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +from redis_release.models import SlackFormat from redis_release.state_display import DisplayModel, StepStatus from .bht.state import ( @@ -44,6 +45,7 @@ def init_slack_printer( slack_channel_id: Optional[str], thread_ts: Optional[str] = None, reply_broadcast: bool = False, + slack_format: SlackFormat = SlackFormat.DEFAULT, ) -> "SlackStatePrinter": """Initialize SlackStatePrinter with validation. @@ -52,6 +54,7 @@ def init_slack_printer( slack_channel_id: Slack channel ID to post to thread_ts: Optional thread timestamp to post messages in a thread reply_broadcast: If True and thread_ts is set, also show in main channel + slack_format: Slack message format (default or one-step) Returns: SlackStatePrinter instance @@ -69,7 +72,9 @@ def init_slack_printer( "Slack token not provided. Use slack_token argument or set SLACK_BOT_TOKEN environment variable" ) - return SlackStatePrinter(token, slack_channel_id, thread_ts, reply_broadcast) + return SlackStatePrinter( + token, slack_channel_id, thread_ts, reply_broadcast, slack_format + ) class SlackStatePrinter: @@ -81,6 +86,7 @@ def __init__( slack_channel_id: str, thread_ts: Optional[str] = None, reply_broadcast: bool = False, + slack_format: SlackFormat = SlackFormat.DEFAULT, ): """Initialize the Slack printer. @@ -89,11 +95,13 @@ def __init__( slack_channel_id: Slack channel ID to post messages to thread_ts: Optional thread timestamp to post messages in a thread reply_broadcast: If True and thread_ts is set, also show in main channel + slack_format: Slack message format (default or one-step) """ self.client = WebClient(token=slack_token) self.channel_id: str = slack_channel_id self.thread_ts = thread_ts self.reply_broadcast = reply_broadcast + self.slack_format = slack_format self.message_ts: Optional[str] = None self.last_blocks_json: Optional[str] = None self.started_at = datetime.now(timezone.utc) @@ -392,6 +400,9 @@ def _collect_workflow_details_slack( self._format_steps_for_slack(workflow_status[1], workflow_name) ) + if self.slack_format == SlackFormat.ONE_STEP: + details = details[-1:] + return "\n".join(details) def _format_steps_for_slack( @@ -407,7 +418,7 @@ def _format_steps_for_slack( List of formatted step strings """ details: List[str] = [] - details.append(f"*{prefix}*") + # details.append(f"*{prefix}*") for step_status, step_name, step_message in steps: if step_status == StepStatus.SUCCEEDED: