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

1# /////////////////////////////////////////////////////////////// 

2# GITHUB_PUBLISHER - GitHub Releases publisher via the gh CLI 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7GitHub publisher - Creates GitHub Releases through the `gh` CLI. 

8 

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""" 

13 

14from __future__ import annotations 

15 

16# /////////////////////////////////////////////////////////////// 

17# IMPORTS 

18# /////////////////////////////////////////////////////////////// 

19# Standard library imports 

20import re 

21from pathlib import Path 

22 

23# Local imports 

24from ..shared.exceptions import PublishError 

25from .base_publisher import BasePublisher 

26 

27# /////////////////////////////////////////////////////////////// 

28# CONSTANTS 

29# /////////////////////////////////////////////////////////////// 

30 

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" 

38 

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") 

42 

43# /////////////////////////////////////////////////////////////// 

44# CLASSES 

45# /////////////////////////////////////////////////////////////// 

46 

47 

48class GitHubPublisher(BasePublisher): 

49 """ 

50 Publishes GitHub Releases through the `gh` CLI. 

51 

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. 

56 

57 Example: 

58 >>> publisher = GitHubPublisher({"repo": "neuraaak/ezcompiler"}) 

59 >>> publisher.get_publisher_name() 

60 'GitHub Releases (gh)' 

61 """ 

62 

63 _cli_name = "gh" 

64 _install_hint = "https://cli.github.com/" 

65 

66 # //////////////////////////////////////////////// 

67 # PUBLIC METHODS 

68 # //////////////////////////////////////////////// 

69 

70 def exists(self, tag: str) -> bool: 

71 """ 

72 Whether a GitHub Release already exists for ``tag``. 

73 

74 Args: 

75 tag: Release tag to look up. 

76 

77 Returns: 

78 bool: True when the release exists. 

79 

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 

90 

91 stderr = result.stderr or "" 

92 if _RELEASE_NOT_FOUND in stderr.lower(): 

93 return False 

94 

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 ) 

102 

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``. 

115 

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. 

123 

124 Returns: 

125 str: URL of the created release, as printed by `gh`. 

126 

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() 

135 

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)] 

148 

149 result = self._run_cli(args) 

150 return result.stdout.strip() 

151 

152 def get_publisher_name(self) -> str: 

153 """ 

154 Get the name of this publisher. 

155 

156 Returns: 

157 str: Human-readable publisher name. 

158 """ 

159 return "GitHub Releases (gh)" 

160 

161 # //////////////////////////////////////////////// 

162 # PRIVATE METHODS 

163 # //////////////////////////////////////////////// 

164 

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 [] 

169 

170 @staticmethod 

171 def _validate_tag(tag: str) -> None: 

172 """ 

173 Reject a tag that `gh` would parse as an option. 

174 

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}'.") 

180 

181 @staticmethod 

182 def _validate_assets(assets: list[Path]) -> None: 

183 """ 

184 Ensure every asset exists and is not option-like. 

185 

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 )