Skip to content

Commit 708e69e

Browse files
committed
chore(Android): Rework pipeline to Build the prod app during release and include the changes into a Play Store release
1 parent 51f8ca3 commit 708e69e

File tree

6 files changed

+449
-51
lines changed

6 files changed

+449
-51
lines changed
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
"""Fetch the highest version code from Google Play Console.
2+
3+
This script queries all release tracks (or specified tracks) and returns
4+
the maximum version code found, along with per-track breakdown.
5+
6+
CLI Usage:
7+
python fetch_version.py --package-name com.example.app --credentials creds.json --output result.json
8+
9+
Requirements:
10+
pip install typer google-auth google-api-python-client
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
from datetime import datetime, timezone
17+
from pathlib import Path
18+
from typing import TYPE_CHECKING, Annotated, Any
19+
20+
import typer
21+
from google.oauth2 import service_account
22+
from googleapiclient.discovery import build
23+
from googleapiclient.errors import HttpError
24+
25+
if TYPE_CHECKING:
26+
from googleapiclient._apis.androidpublisher.v3 import AndroidPublisherResource
27+
28+
29+
# =============================================================================
30+
# Exception Classes
31+
# =============================================================================
32+
33+
34+
class InputValidationError(Exception):
35+
"""Raised when input validation fails.
36+
37+
Examples:
38+
- Required file not found
39+
- Invalid JSON format
40+
- Missing required fields
41+
"""
42+
43+
def __init__(self, message: str) -> None:
44+
self.message = message
45+
super().__init__(message)
46+
47+
48+
class CredentialsError(Exception):
49+
"""Raised when credentials are invalid or insufficient.
50+
51+
Examples:
52+
- Invalid service account JSON
53+
- Expired or revoked tokens
54+
- Insufficient permissions
55+
"""
56+
57+
def __init__(self, message: str) -> None:
58+
self.message = message
59+
super().__init__(message)
60+
61+
62+
class ApiError(Exception):
63+
"""Raised when an external API call fails.
64+
65+
Attributes:
66+
message: Human-readable error description
67+
service: Name of the service that failed (e.g., 'google_play')
68+
status_code: HTTP status code if applicable, None otherwise
69+
"""
70+
71+
def __init__(
72+
self,
73+
message: str,
74+
service: str,
75+
status_code: int | None = None,
76+
) -> None:
77+
self.message = message
78+
self.service = service
79+
self.status_code = status_code
80+
super().__init__(message)
81+
82+
def __str__(self) -> str:
83+
if self.status_code:
84+
return f"[{self.service}] {self.message} (status: {self.status_code})"
85+
return f"[{self.service}] {self.message}"
86+
87+
88+
# =============================================================================
89+
# Utility Functions
90+
# =============================================================================
91+
92+
93+
def create_timestamp() -> str:
94+
"""Create an ISO 8601 timestamp in UTC.
95+
96+
Returns:
97+
ISO 8601 formatted timestamp string (e.g., "2026-01-23T21:05:00Z")
98+
"""
99+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
100+
101+
102+
def write_json_output(data: dict[str, Any], output_path: Path) -> None:
103+
"""Write a dictionary to a JSON file.
104+
105+
Creates parent directories if they don't exist.
106+
Uses 2-space indentation for readability.
107+
108+
Args:
109+
data: Dictionary to serialize as JSON
110+
output_path: Path to the output file
111+
112+
Raises:
113+
OSError: If the file cannot be written
114+
"""
115+
output_path.parent.mkdir(parents=True, exist_ok=True)
116+
117+
with output_path.open("w", encoding="utf-8") as f:
118+
json.dump(data, f, indent=2, ensure_ascii=False)
119+
f.write("\n")
120+
121+
122+
# =============================================================================
123+
# Constants
124+
# =============================================================================
125+
126+
# Standard Google Play tracks
127+
DEFAULT_TRACKS = ["internal", "alpha", "beta", "production"]
128+
129+
# Required scope for reading app information
130+
SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]
131+
132+
133+
# =============================================================================
134+
# Typer App
135+
# =============================================================================
136+
137+
app = typer.Typer(
138+
name="version-fetcher",
139+
help="Fetch the highest version code from Google Play Console.",
140+
no_args_is_help=True,
141+
)
142+
143+
144+
# =============================================================================
145+
# Core Functions
146+
# =============================================================================
147+
148+
149+
def create_play_service(credentials_path: Path) -> AndroidPublisherResource:
150+
"""Create an authenticated Google Play Developer API service.
151+
152+
Args:
153+
credentials_path: Path to service account JSON file
154+
155+
Returns:
156+
Authenticated Android Publisher API resource
157+
158+
Raises:
159+
CredentialsError: If credentials file is invalid
160+
InputValidationError: If credentials file is not found
161+
"""
162+
if not credentials_path.exists():
163+
raise InputValidationError(f"Credentials file not found: {credentials_path}")
164+
165+
try:
166+
credentials = service_account.Credentials.from_service_account_file(
167+
str(credentials_path),
168+
scopes=SCOPES,
169+
)
170+
return build("androidpublisher", "v3", credentials=credentials)
171+
except ValueError as e:
172+
raise CredentialsError(f"Invalid service account credentials: {e}") from e
173+
174+
175+
def get_track_version_codes(
176+
service: AndroidPublisherResource,
177+
package_name: str,
178+
track: str,
179+
) -> list[int]:
180+
"""Get all version codes for a specific track.
181+
182+
Args:
183+
service: Authenticated Android Publisher API resource
184+
package_name: Android app package name
185+
track: Track name (internal, alpha, beta, production)
186+
187+
Returns:
188+
List of version codes for the track (empty if no releases)
189+
190+
Raises:
191+
ApiError: If the API call fails
192+
"""
193+
try:
194+
# Create an edit session (read-only, we won't commit)
195+
edit_request = service.edits().insert(body={}, packageName=package_name)
196+
edit = edit_request.execute()
197+
edit_id = edit["id"]
198+
199+
try:
200+
# Get track info
201+
track_response = (
202+
service.edits()
203+
.tracks()
204+
.get(
205+
packageName=package_name,
206+
editId=edit_id,
207+
track=track,
208+
)
209+
.execute()
210+
)
211+
212+
version_codes = []
213+
releases = track_response.get("releases", [])
214+
for release in releases:
215+
codes = release.get("versionCodes", [])
216+
version_codes.extend(int(code) for code in codes)
217+
218+
return version_codes
219+
220+
finally:
221+
# Always delete the edit (we don't need to commit)
222+
service.edits().delete(packageName=package_name, editId=edit_id).execute()
223+
224+
except HttpError as e:
225+
if e.resp.status == 404:
226+
# Track doesn't exist or has no releases
227+
return []
228+
raise ApiError(
229+
f"Failed to get track info for '{track}': {e.reason}",
230+
service="google_play",
231+
status_code=e.resp.status,
232+
) from e
233+
234+
235+
def fetch_max_version_code(
236+
package_name: str,
237+
credentials_path: Path,
238+
service: AndroidPublisherResource | None = None,
239+
) -> dict:
240+
"""Fetch the maximum version code across all specified tracks.
241+
242+
Args:
243+
package_name: Android app package name (e.g., 'com.example.app')
244+
credentials_path: Path to service account JSON credentials
245+
service: Optional pre-configured service (for testing)
246+
247+
Returns:
248+
Dictionary with version information:
249+
{
250+
"max_version_code": int,
251+
"next_version_code": int,
252+
"versions_by_track": dict[str, int],
253+
"package_name": str,
254+
"timestamp": str
255+
}
256+
257+
Raises:
258+
InputValidationError: If credentials file not found
259+
CredentialsError: If credentials are invalid
260+
ApiError: If API call fails
261+
"""
262+
if service is None:
263+
service = create_play_service(credentials_path)
264+
265+
tracks_to_check = DEFAULT_TRACKS
266+
versions_by_track: dict[str, int] = {}
267+
268+
for track in tracks_to_check:
269+
version_codes = get_track_version_codes(service, package_name, track)
270+
# Use max version code for track, or 0 if no versions
271+
versions_by_track[track] = max(version_codes) if version_codes else 0
272+
273+
max_version = max(versions_by_track.values()) if versions_by_track else 0
274+
275+
return {
276+
"max_version_code": max_version,
277+
"next_version_code": max_version + 1,
278+
"versions_by_track": versions_by_track,
279+
"package_name": package_name,
280+
"timestamp": create_timestamp(),
281+
}
282+
283+
284+
# =============================================================================
285+
# CLI Command
286+
# =============================================================================
287+
288+
289+
@app.command()
290+
def main(
291+
package_name: Annotated[
292+
str,
293+
typer.Option(
294+
"--package-name",
295+
"-p",
296+
help="Android app package name (e.g., com.example.app)",
297+
),
298+
],
299+
credentials: Annotated[
300+
Path,
301+
typer.Option(
302+
"--credentials",
303+
"-c",
304+
help="Path to service account JSON file",
305+
exists=False, # We handle existence check ourselves for better error messages
306+
),
307+
],
308+
output: Annotated[
309+
Path,
310+
typer.Option(
311+
"--output",
312+
"-o",
313+
help="Path to output JSON file",
314+
),
315+
],
316+
) -> None:
317+
"""Fetch the highest version code from Google Play Console.
318+
319+
Queries all release tracks (or specified tracks) and outputs a JSON file
320+
with the maximum version code found and per-track breakdown.
321+
"""
322+
try:
323+
result = fetch_max_version_code(
324+
package_name=package_name,
325+
credentials_path=credentials,
326+
)
327+
write_json_output(result, output)
328+
typer.echo(f"Version info written to {output}")
329+
typer.echo(f"Max version code: {result['max_version_code']}")
330+
typer.echo(f"Next version code: {result['next_version_code']}")
331+
332+
except (InputValidationError, CredentialsError, ApiError) as e:
333+
typer.echo(f"Error: {e}", err=True)
334+
raise typer.Exit(code=1) from e
335+
336+
337+
if __name__ == "__main__":
338+
app()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
typer>=0.9.0
2+
google-auth>=2.23.0
3+
google-api-python-client>=2.100.0

.github/workflows/new_daily_tag.yaml

Lines changed: 0 additions & 33 deletions
This file was deleted.

0 commit comments

Comments
 (0)