Coverage for src/ezcompiler/adapters/_github_publisher.py: 100.00%
55 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# GITHUB_PUBLISHER - GitHub Releases publisher via the gh CLI
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7GitHub publisher - Creates GitHub Releases through the `gh` CLI.
9Authentication is inherited from the environment (``GH_TOKEN`` or a prior
10``gh auth login``): no credential ever transits through the configuration
11or the command line.
12"""
14from __future__ import annotations
16# ///////////////////////////////////////////////////////////////
17# IMPORTS
18# ///////////////////////////////////////////////////////////////
19# Standard library imports
20import re
21from pathlib import Path
23# Local imports
24from ..shared.exceptions import PublishError
25from .base_publisher import BasePublisher
27# ///////////////////////////////////////////////////////////////
28# CONSTANTS
29# ///////////////////////////////////////////////////////////////
31# Fragments through which `gh release view` reports an absence, by
32# opposition a un echec d'authentification, de reseau ou de permission.
33# Telling the two apart is critical: treating a 401 as an absence
34# would publish over an existing release.
35# Only "release not found" is kept: an "HTTP 404" or an unreachable repo
36# does not attest that the release is absent.
37_RELEASE_NOT_FOUND = "release not found"
39# The HTTP status is the only stderr fragment copied into the error: stderr
40# peut porter un secret (voir BasePublisher._run_cli).
41_HTTP_STATUS = re.compile(r"\bHTTP (\d{3})\b")
43# ///////////////////////////////////////////////////////////////
44# CLASSES
45# ///////////////////////////////////////////////////////////////
48class GitHubPublisher(BasePublisher):
49 """
50 Publishes GitHub Releases through the `gh` CLI.
52 Args:
53 config: Optional dict. Key ``repo`` scopes every call to a given
54 ``owner/repo``; when absent, `gh` infers the repository from the
55 current directory's git remote.
57 Example:
58 >>> publisher = GitHubPublisher({"repo": "neuraaak/ezcompiler"})
59 >>> publisher.get_publisher_name()
60 'GitHub Releases (gh)'
61 """
63 _cli_name = "gh"
64 _install_hint = "https://cli.github.com/"
66 # ////////////////////////////////////////////////
67 # PUBLIC METHODS
68 # ////////////////////////////////////////////////
70 def exists(self, tag: str) -> bool:
71 """
72 Whether a GitHub Release already exists for ``tag``.
74 Args:
75 tag: Release tag to look up.
77 Returns:
78 bool: True when the release exists.
80 Raises:
81 PublishError: If existence cannot be determined — an auth,
82 network or permission failure is never reported as absence.
83 """
84 self._validate_tag(tag)
85 result = self._run_cli(
86 ["release", "view", tag, *self._repo_args()], check=False
87 )
88 if result.returncode == 0:
89 return True
91 stderr = result.stderr or ""
92 if _RELEASE_NOT_FOUND in stderr.lower():
93 return False
95 http = _HTTP_STATUS.search(stderr)
96 status = f", HTTP {http.group(1)}" if http else ""
97 raise PublishError(
98 f"Cannot determine whether release '{tag}' exists "
99 f"(code {result.returncode}{status}). "
100 "Publication aborted so an existing release is not overwritten."
101 )
103 def publish(
104 self,
105 assets: list[Path],
106 *,
107 tag: str,
108 title: str,
109 notes: str | None = None,
110 prerelease: bool = False,
111 draft: bool = False,
112 ) -> str:
113 """
114 Create a GitHub Release and attach ``assets``.
116 Args:
117 assets: Files to attach. Must be a non-empty list of existing files.
118 tag: Release tag.
119 title: Release title.
120 notes: Release body. ``None`` requests `--generate-notes`.
121 prerelease: Mark as pre-release.
122 draft: Create unpublished.
124 Returns:
125 str: URL of the created release, as printed by `gh`.
127 Raises:
128 PublishError: If validation, authentication or `gh` fails.
129 """
130 # Local checks first: they are cheap and must not hide behind a
131 # missing or unauthenticated gh.
132 self._validate_tag(tag)
133 self._validate_assets(assets)
134 self._check_auth()
136 args = ["release", "create", tag, *self._repo_args(), "--title", title]
137 if notes is None:
138 args.append("--generate-notes")
139 else:
140 args += ["--notes", notes]
141 if prerelease:
142 args.append("--prerelease")
143 if draft:
144 args.append("--draft")
145 # `--` closes the option list: an asset named `-x.zip` can no longer
146 # be read as a flag by gh (symmetric with _validate_tag).
147 args += ["--", *(str(asset) for asset in assets)]
149 result = self._run_cli(args)
150 return result.stdout.strip()
152 def get_publisher_name(self) -> str:
153 """
154 Get the name of this publisher.
156 Returns:
157 str: Human-readable publisher name.
158 """
159 return "GitHub Releases (gh)"
161 # ////////////////////////////////////////////////
162 # PRIVATE METHODS
163 # ////////////////////////////////////////////////
165 def _repo_args(self) -> list[str]:
166 """Return ``--repo owner/repo`` when configured, else nothing."""
167 repo = self._config.get("repo")
168 return ["--repo", str(repo)] if repo else []
170 @staticmethod
171 def _validate_tag(tag: str) -> None:
172 """
173 Reject a tag that `gh` would parse as an option.
175 Raises:
176 PublishError: If the tag is empty or starts with ``-``.
177 """
178 if not tag or tag.startswith("-"):
179 raise PublishError(f"Tag de release invalide : '{tag}'.")
181 @staticmethod
182 def _validate_assets(assets: list[Path]) -> None:
183 """
184 Ensure every asset exists and is not option-like.
186 Raises:
187 PublishError: If the list is empty, a path starts with ``-`` (gh
188 would read it as a flag), or a file is missing.
189 """
190 if not assets:
191 raise PublishError("No asset to publish. Run `ezcompiler compile` first.")
192 option_like = [str(a) for a in assets if str(a).startswith("-")]
193 if option_like:
194 raise PublishError(
195 f"Invalid asset path: {', '.join(option_like)} — gh would "
196 "read it as an option. Use an absolute path."
197 )
198 missing = [str(a) for a in assets if not a.is_file()]
199 if missing:
200 raise PublishError(
201 f"Asset not found: {', '.join(missing)}. "
202 "Run `ezcompiler compile` first."
203 )