1+ #!/usr/bin/env python3
2+
3+ import subprocess
4+ import json
5+ import os
6+ import sys
7+ import shutil
8+ import re
9+ import tomllib
10+ from dataclasses import dataclass
11+ import tempfile
12+ import base64
13+ import string
14+ import glob
15+
16+
17+ # Number of versions to keep in the fdroid repo. Will remove all the older versions.
18+ KEEP_FDROID_VERSIONS = 4
19+
20+
21+ @dataclass
22+ class BuildResult :
23+ max_version_code : int
24+ version_name : str
25+ apk_paths : list [str ]
26+ bundle_path : str
27+ package_id : str
28+
29+ @dataclass
30+ class BuildCredentials :
31+ keystore_b64 : str
32+ keystore_password : str
33+ key_alias : str
34+ key_password : str
35+
36+ def __init__ (self , credentials : dict ):
37+ self .keystore_b64 = credentials ['keystore' ].strip ()
38+ self .keystore_password = credentials ['keystore_password' ]
39+ self .key_alias = credentials ['key_alias' ]
40+ self .key_password = credentials ['key_password' ]
41+
42+ def build_releases (project_root : str , flavor : str , credentials_property_prefix : str , credentials : BuildCredentials , huawei : bool = False ) -> BuildResult :
43+ (keystore_fd , keystore_file ) = tempfile .mkstemp (prefix = 'keystore_' , suffix = '.jks' , dir = os .path .join (project_root , 'build' ))
44+ try :
45+ with os .fdopen (keystore_fd , 'wb' ) as f :
46+ f .write (base64 .b64decode (credentials .keystore_b64 ))
47+
48+ gradle_commands = f"""./gradlew \
49+ -P{ credentials_property_prefix } _STORE_FILE='{ keystore_file } '\
50+ -P{ credentials_property_prefix } _STORE_PASSWORD='{ credentials .keystore_password } ' \
51+ -P{ credentials_property_prefix } _KEY_ALIAS='{ credentials .key_alias } ' \
52+ -P{ credentials_property_prefix } _KEY_PASSWORD='{ credentials .key_password } '"""
53+
54+ if huawei :
55+ gradle_commands += ' -Phuawei '
56+
57+ subprocess .run (f"""{ gradle_commands } \
58+ assemble{ flavor .capitalize ()} Release \
59+ bundle{ flavor .capitalize ()} Release --stacktrace""" , shell = True , check = True , cwd = project_root )
60+
61+ apk_output_dir = os .path .join (project_root , f'app/build/outputs/apk/{ flavor } /release' )
62+
63+ with open (os .path .join (apk_output_dir , 'output-metadata.json' )) as f :
64+ play_outputs = json .load (f )
65+
66+ apks = [os .path .join (apk_output_dir , f ['outputFile' ]) for f in play_outputs ['elements' ]]
67+ max_version_code = max (map (lambda element : element ['versionCode' ], play_outputs ['elements' ]))
68+ package_id = play_outputs ['applicationId' ]
69+ version_name = play_outputs ['elements' ][0 ]['versionName' ]
70+
71+ print ('Max version code is: ' , max_version_code )
72+
73+ return BuildResult (max_version_code = max_version_code ,
74+ apk_paths = apks ,
75+ package_id = package_id ,
76+ version_name = version_name ,
77+ bundle_path = os .path .join (project_root , f'app/build/outputs/bundle/{ flavor } Release/session-{ version_name } -{ flavor } -release.aab' ))
78+
79+ finally :
80+ print (f'Cleaning up keystore file: { keystore_file } ' )
81+ os .remove (keystore_file )
82+
83+
84+ project_root = os .path .dirname (sys .path [0 ])
85+ credentials_file_path = os .path .join (project_root , 'release-creds.toml' )
86+ fdroid_repo_path = os .path .join (project_root , 'build/fdroidrepo' )
87+
88+ def detect_android_sdk () -> str :
89+ sdk_dir = os .environ .get ('ANDROID_HOME' )
90+ if sdk_dir is None :
91+ with open (os .path .join (project_root , 'local.properties' )) as f :
92+ matched = next (re .finditer (r'^sdk.dir=(.+?)$' , f .read (), re .MULTILINE ), None )
93+ sdk_dir = matched .group (1 ) if matched else None
94+
95+ if sdk_dir is None or not os .path .isdir (sdk_dir ):
96+ raise Exception ('Android SDK not found. Please set ANDROID_HOME or add sdk.dir to local.properties' )
97+
98+ return sdk_dir
99+
100+
101+ def update_fdroid (build : BuildResult , fdroid_workspace : str , creds : BuildCredentials ):
102+ # Check if there's a git repo at the fdroid repo path by running git status
103+ try :
104+ subprocess .check_call (f'git -C { fdroid_repo_path } status' , shell = True , stdout = subprocess .DEVNULL , stderr = subprocess .DEVNULL )
105+ subprocess .check_call (f'git fetch' , shell = True , cwd = fdroid_workspace )
106+ print (f'Found fdroid git repo at { fdroid_repo_path } ' )
107+ except subprocess .CalledProcessError :
108+ print (f'No fdroid git repo found at { fdroid_repo_path } . Cloning using gh.' )
109+ subprocess .run (f'gh repo clone session-foundation/session-fdroid { fdroid_repo_path } -- --depth=1' , shell = True , check = True )
110+
111+ # Create a branch for the release
112+ print (f'Creating a branch for the fdroid release: { build .version_name } ' )
113+ try :
114+ branch_name = f'release/{ build .version_name } '
115+ # Clean and switch to master before doing anything
116+ subprocess .check_call (f'git reset --hard HEAD && git checkout master' , shell = True , cwd = fdroid_workspace , stdout = subprocess .DEVNULL , stderr = subprocess .DEVNULL )
117+
118+ # Delete the existing local branch regardlessly
119+ subprocess .run (f'git branch -D { branch_name } ' , check = False , shell = True , cwd = fdroid_workspace )
120+
121+ # Check if the remote branch already exists, or we need to create a new one
122+ try :
123+ subprocess .check_call (f'git ls-remote --exit-code origin refs/heads/{ branch_name } ' , shell = True , cwd = fdroid_workspace , stdout = subprocess .DEVNULL , stderr = subprocess .DEVNULL )
124+ print (f'Branch { branch_name } already exists. Checking out...' )
125+ subprocess .check_call (f'git checkout { branch_name } ' , shell = True , cwd = fdroid_workspace )
126+ except subprocess .CalledProcessError :
127+ print (f'Branch { branch_name } not found. Creating a new branch.' )
128+ subprocess .check_call (f'git checkout -b { branch_name } origin/master' , shell = True , cwd = fdroid_workspace )
129+
130+ except subprocess .CalledProcessError :
131+ print (f'Failed to create a branch for the release. ' )
132+ sys .exit (1 )
133+
134+ # Copy the apks to the fdroid repo
135+ for apk in build .apk_paths :
136+ if apk .endswith ('-universal.apk' ):
137+ print ('Skipping universal apk:' , apk )
138+ continue
139+
140+ dst = os .path .join (fdroid_workspace , 'repo/' + os .path .basename (apk ))
141+ print ('Copying' , apk , 'to' , dst )
142+ shutil .copy (apk , dst )
143+
144+ # Make sure there are only last three versions of APKs
145+ all_apk_versions_and_ctime = [(re .search (r'session-(.+?)-' , os .path .basename (name )).group (1 ), os .path .getctime (name ))
146+ for name in glob .glob (os .path .join (fdroid_workspace , 'repo/session-*-arm64-v8a.apk' ))]
147+ # Sort by ctime DESC
148+ all_apk_versions_and_ctime .sort (key = lambda x : x [1 ], reverse = True )
149+ # Remove all but the last three versions
150+ for version , _ in all_apk_versions_and_ctime [KEEP_FDROID_VERSIONS :]:
151+ for apk in glob .glob (os .path .join (fdroid_workspace , f'repo/session-{ version } -*.apk' )):
152+ print ('Removing old apk:' , apk )
153+ os .remove (apk )
154+
155+ # Update the metadata file
156+ metadata_file = os .path .join (fdroid_workspace , f'metadata/{ build .package_id } .yml' )
157+ with open (f'{ metadata_file } .tpl' , 'r' ) as template_file :
158+ metadata_template = string .Template (template_file .read ())
159+ metadata_contents = metadata_template .substitute ({
160+ 'currentVersionCode' : build .max_version_code ,
161+ })
162+ with open (metadata_file , 'w' ) as file :
163+ file .write (metadata_contents )
164+
165+ [keystore_fd , keystore_path ] = tempfile .mkstemp (prefix = 'fdroid_keystore_' , suffix = '.p12' , dir = os .path .join (project_root , 'build' ))
166+ config_file_path = os .path .join (fdroid_workspace , 'config.yml' )
167+
168+ try :
169+ android_sdk = detect_android_sdk ()
170+ with os .fdopen (keystore_fd , 'wb' ) as f :
171+ f .write (base64 .b64decode (creds .keystore_b64 ))
172+
173+ # Read the config template and create a config file
174+ with open (f'{ config_file_path } .tpl' ) as config_template_file :
175+ config_template = string .Template (config_template_file .read ())
176+ with open (config_file_path , 'w' ) as f :
177+ f .write (config_template .substitute ({
178+ 'keystore_file' : keystore_path ,
179+ 'keystore_pass' : creds .keystore_password ,
180+ 'repo_keyalias' : creds .key_alias ,
181+ 'key_pass' : creds .key_password ,
182+ 'android_sdk' : android_sdk
183+ }))
184+
185+
186+ # Run fdroid update
187+ print ("Running fdroid update..." )
188+ environs = os .environ .copy ()
189+ subprocess .run ('fdroid update' , shell = True , check = True , cwd = fdroid_workspace , env = environs )
190+ finally :
191+ print (f'Cleaning up...' )
192+ if os .path .exists (metadata_file ):
193+ os .remove (metadata_file )
194+
195+ if os .path .exists (keystore_path ):
196+ os .remove (keystore_path )
197+
198+ if os .path .exists (config_file_path ):
199+ os .remove (config_file_path )
200+
201+ # Commit the changes
202+ print ('Committing the changes...' )
203+ subprocess .run (f'git add . && git commit -am "Prepare for release { build .version_name } "' , shell = True , check = True , cwd = fdroid_workspace )
204+
205+ # Create Pull Request for releases
206+ print ('Creating a pull request...' )
207+ subprocess .run (f'''\
208+ gh pr create --base master \
209+ --title "Release { build .version_name } " \
210+ --body "This is an automated release preparation for Release { build .version_name } . Human beings are still required to approve and merge this PR."\
211+ ''' , shell = True , check = True , cwd = fdroid_workspace )
212+
213+
214+ # Make sure gh command is available
215+ if shutil .which ('gh' ) is None :
216+ print ('`gh` command not found. It is required to automate fdroid releases. Please install it from https://cli.github.com/' , file = sys .stderr )
217+ sys .exit (1 )
218+
219+ # Make sure credentials file exists
220+ if not os .path .isfile (credentials_file_path ):
221+ print (f'Credentials file not found at { credentials_file_path } . You should ask the project maintainer for the file.' , file = sys .stderr )
222+ sys .exit (1 )
223+
224+ with open (credentials_file_path , 'rb' ) as f :
225+ credentials = tomllib .load (f )
226+
227+
228+ print ("Building play releases..." )
229+ play_build_result = build_releases (
230+ project_root = project_root ,
231+ flavor = 'play' ,
232+ credentials = BuildCredentials (credentials ['build' ]['play' ]),
233+ credentials_property_prefix = 'SESSION'
234+ )
235+
236+ print ("Updating fdroid repo..." )
237+ update_fdroid (build = play_build_result , creds = BuildCredentials (credentials ['fdroid' ]), fdroid_workspace = os .path .join (fdroid_repo_path , 'fdroid' ))
238+
239+ print ("Building huawei releases..." )
240+ huawei_build_result = build_releases (
241+ project_root = project_root ,
242+ flavor = 'huawei' ,
243+ credentials = BuildCredentials (credentials ['build' ]['huawei' ]),
244+ credentials_property_prefix = 'SESSION_HUAWEI' ,
245+ huawei = True
246+ )
247+
248+ # If the a github release draft exists, upload the apks to the release
249+ try :
250+ release_info = json .loads (subprocess .check_output (f'gh release view --json isDraft { play_build_result .version_name } ' , shell = True , cwd = project_root ))
251+ if release_info ['draft' ] == True :
252+ print (f'Uploading build artifact to the release { play_build_result .version_name } draft...' )
253+ files_to_upload = [* play_build_result .apk_paths ,
254+ play_build_result .bundle_path ,
255+ * huawei_build_result .apk_paths ]
256+ upload_commands = ['gh' , 'release' , 'upload' , play_build_result .version_name , '--clobber' , * files_to_upload ]
257+ subprocess .run (upload_commands , shell = False , cwd = project_root , check = True )
258+
259+ print ('Successfully uploaded these files to the draft release: ' )
260+ for file in files_to_upload :
261+ print (file )
262+ else :
263+ print (f'Release { play_build_result .version_name } not a draft. Skipping upload of apks to the release.' )
264+ except subprocess .CalledProcessError :
265+ print (f'{ play_build_result .version_name } has not had a release draft created. Skipping upload of apks to the release.' )
266+
267+
268+ print ('\n =====================' )
269+ print ('Build result: ' )
270+ print ('Play:' )
271+ for apk in play_build_result .apk_paths :
272+ print (f'\t { apk } ' )
273+ print (f'\t { play_build_result .bundle_path } ' )
274+
275+ print ('Huawei:' )
276+ for apk in huawei_build_result .apk_paths :
277+ print (f'\t { apk } ' )
278+ print ('=====================' )
0 commit comments