Coverage for src/ezcompiler/services/publish_service.py: 94.94%
61 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
1# ///////////////////////////////////////////////////////////////
2# PUBLISH_SERVICE - Publication orchestration (update tree & release)
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7Publish service - Orchestrates the two publication paths.
9``publish_update`` transfers the signed TUF tree; ``publish_release``
10either creates a platform release (github) or copies the assets to a file
11destination (disk/server/r2). This module owns the routing between the two
12and is the only place that decides it.
14The human-facing confirmation lives in ``interfaces/``, never here: a
15service must not read stdin.
16"""
18from __future__ import annotations
20# ///////////////////////////////////////////////////////////////
21# IMPORTS
22# ///////////////////////////////////////////////////////////////
23# Standard library imports
24import logging
25from pathlib import Path
26from typing import TYPE_CHECKING, Any
28# Third-party imports
29# Local imports
30from .._types import PublisherPort
31from ..adapters import PublisherFactory
32from ..shared import ReleasePreflight
33from ..shared._compiler_config import _OWNER_REPO_RE
34from ..shared.exceptions import PublishError, UploadError
35from .pipeline_service import PipelineService
36from .tuf_service import TufService
37from .uploader_service import UploaderService
39if TYPE_CHECKING: 39 ↛ 40line 39 didn't jump to line 40 because the condition on line 39 was never true
40 from ..shared import CompilerConfig
42# ///////////////////////////////////////////////////////////////
43# CONSTANTS
44# ///////////////////////////////////////////////////////////////
46_PLATFORMS: tuple[str, ...] = ("github", "gitlab")
48logger = logging.getLogger(__name__)
50# ///////////////////////////////////////////////////////////////
51# CLASSES
52# ///////////////////////////////////////////////////////////////
55class PublishService:
56 """Orchestrates TUF-tree and release publication."""
58 # ////////////////////////////////////////////////
59 # ROUTING
60 # ////////////////////////////////////////////////
62 @staticmethod
63 def resolve_publisher(
64 config: CompilerConfig, release_destination: str | None = None
65 ) -> PublisherPort | None:
66 """Return a publisher for platform destinations, else ``None``.
68 Args:
69 config: Current configuration.
70 release_destination: Override for ``config.release_destination``.
72 Returns:
73 PublisherPort | None: ``None`` when the destination is a file
74 backend (disk/server/r2) and the uploader path applies.
76 Raises:
77 PublisherTypeError: If the platform is not supported.
78 PublishError: If ``release_endpoint`` is not an ``owner/repo``.
79 """
80 dest = release_destination or config.release_destination
81 if dest not in _PLATFORMS:
82 return None
83 endpoint = config.release_endpoint
84 # The config only validates the owner/repo form when
85 # release_destination already names a platform: an override
86 # (-rld github) on a disk/r2 config would bring a path or a bucket
87 # through --repo.
88 if endpoint and not _OWNER_REPO_RE.fullmatch(endpoint):
89 raise PublishError(
90 f"release_endpoint '{endpoint}' is not an 'owner/repo' "
91 f"repository usable for '{dest}' publication."
92 )
93 return PublisherFactory.create_publisher(
94 dest, {"repo": endpoint} if endpoint else None
95 )
97 @staticmethod
98 def preflight_release(
99 config: CompilerConfig,
100 *,
101 tag: str,
102 release_destination: str | None = None,
103 ) -> ReleasePreflight:
104 """Run every pre-publication check and return the recap to display.
106 The routing decision and the ``PublisherPort`` lifecycle both stay
107 here: the interfaces layer receives a value object, shows it and asks
108 for confirmation. Every costly check runs before that confirmation, so
109 once the operator says yes only a network failure remains.
111 Args:
112 config: Current configuration.
113 tag: Release tag that would be created (platform path only).
114 release_destination: Override for ``config.release_destination``.
116 Returns:
117 ReleasePreflight: Recap of what the publication would do.
119 Raises:
120 PublishError: If the CLI is missing or unauthenticated, or if a
121 release already exists for ``tag``.
122 PublisherTypeError: If the platform is not supported.
123 ReleaseError: If an enabled installer is missing, or nothing was
124 built.
125 """
126 dest = release_destination or config.release_destination
127 publisher = PublishService.resolve_publisher(config, release_destination)
128 if publisher is None:
129 return ReleasePreflight(destination=dest, is_platform=False)
131 publisher.preflight()
132 if publisher.exists(tag):
133 raise PublishError(
134 f"Release {tag} already exists. Delete it or change version."
135 )
136 assets = PipelineService.stage_versioned_assets(config)
137 return ReleasePreflight(
138 destination=dest,
139 is_platform=True,
140 publisher_name=publisher.get_publisher_name(),
141 repo=config.release_endpoint or None,
142 assets=tuple(assets),
143 )
145 # ////////////////////////////////////////////////
146 # PUBLICATION
147 # ////////////////////////////////////////////////
149 @staticmethod
150 def publish_update(
151 config: CompilerConfig,
152 *,
153 destination: str | None = None,
154 repo_destination: str | None = None,
155 upload_config: dict[str, Any] | None = None,
156 ) -> None:
157 """Transfer the signed TUF tree to the update backend.
159 Never routed to a platform release: a TUF tree must be served over
160 HTTP on stable paths, which release assets do not provide.
162 Args:
163 config: Current configuration.
164 destination: Override for the resolved repo destination.
165 repo_destination: Override for ``config.repo_destination``.
166 upload_config: Extra options forwarded to the uploader.
168 Raises:
169 UploadError: If the transfer fails.
170 """
171 repo_dir = TufService.repo_dir(config)
172 repo_dest = repo_destination or config.repo_destination
173 logger.info("Publishing TUF update tree (%s)", repo_dest)
174 UploaderService.upload_tuf_repo(
175 config, repo_dir, repo_dest, destination, upload_config
176 )
178 @staticmethod
179 def publish_release(
180 config: CompilerConfig,
181 assets: list[Path],
182 *,
183 tag: str,
184 title: str,
185 notes: str | None = None,
186 prerelease: bool = False,
187 draft: bool = False,
188 destination: str | None = None,
189 release_destination: str | None = None,
190 upload_config: dict[str, Any] | None = None,
191 ) -> str | None:
192 """Publish the release assets.
194 Args:
195 config: Current configuration.
196 assets: Files to attach (platform path only). The file path
197 re-assembles ``release/`` exactly as before and ignores it.
198 tag: Release tag (platform path only).
199 title: Release title (platform path only).
200 notes: Release body; ``None`` requests generated notes.
201 prerelease: Mark as pre-release.
202 draft: Create unpublished.
203 destination: Override for the resolved release destination.
204 release_destination: Override for ``config.release_destination``.
205 upload_config: Extra options forwarded to the uploader.
207 Returns:
208 str | None: The release URL on the platform path, ``None`` on the
209 file-destination path.
211 Raises:
212 PublishError: If platform publication fails.
213 UploadError: If the file transfer fails, or nothing was built.
214 """
215 # Routing resolved here and nowhere else: the CLI never handles a
216 # PublisherPort (it goes through preflight_release()).
217 publisher = PublishService.resolve_publisher(config, release_destination)
218 if publisher is None:
219 # File path: delegated as is to the existing code, which puts
220 # the artifacts under <dest>/release/ and handles the r2/server
221 # variants. Do NOT reimplement the upload loop here: the
222 # `release/` subdirectory and the destination branches would be
223 # lost.
224 rel_dest = release_destination or config.release_destination
225 logger.info("Uploading release assets (%s)", rel_dest)
226 release_root = PipelineService.assemble_release_dir(config)
227 # assemble_release_dir only copies what exists: without a
228 # build, an empty directory would be transferred as a success.
229 if not any(release_root.iterdir()):
230 raise UploadError(
231 f"No artifact to publish in {release_root} "
232 "(zip or installer): run the compilation first."
233 )
234 UploaderService.upload_release_zip(
235 config, release_root, rel_dest, destination, upload_config
236 )
237 return None
239 if not assets:
240 raise PublishError(f"No asset to publish for {tag}.")
242 logger.info("Publishing release %s via %s", tag, publisher.get_publisher_name())
243 url = publisher.publish(
244 assets,
245 tag=tag,
246 title=title,
247 notes=notes,
248 prerelease=prerelease,
249 draft=draft,
250 )
251 if not url: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 raise PublishError(
253 f"Publishing {tag} returned no URL — cannot confirm that it succeeded."
254 )
255 return url