inseven-build-tools 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inseven_build_tools-0.1.2/PKG-INFO +12 -0
- inseven_build_tools-0.1.2/README.md +3 -0
- inseven_build_tools-0.1.2/build_tools/__init__.py +21 -0
- inseven_build_tools-0.1.2/build_tools/__main__.py +35 -0
- inseven_build_tools-0.1.2/build_tools/build_tools.py +512 -0
- inseven_build_tools-0.1.2/inseven_build_tools.egg-info/PKG-INFO +12 -0
- inseven_build_tools-0.1.2/inseven_build_tools.egg-info/SOURCES.txt +12 -0
- inseven_build_tools-0.1.2/inseven_build_tools.egg-info/dependency_links.txt +1 -0
- inseven_build_tools-0.1.2/inseven_build_tools.egg-info/entry_points.txt +2 -0
- inseven_build_tools-0.1.2/inseven_build_tools.egg-info/requires.txt +1 -0
- inseven_build_tools-0.1.2/inseven_build_tools.egg-info/top_level.txt +1 -0
- inseven_build_tools-0.1.2/pyproject.toml +19 -0
- inseven_build_tools-0.1.2/setup.cfg +4 -0
- inseven_build_tools-0.1.2/setup.py +28 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inseven-build-tools
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Collection of convenience build tools
|
|
5
|
+
Author-email: Jason Morley <hello@jbmorley.co.uk>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests
|
|
9
|
+
|
|
10
|
+
# Build Tools
|
|
11
|
+
|
|
12
|
+
Collection of convenience build tools
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright (c) 2018-2025 Jason Morley
|
|
2
|
+
#
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
#
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
from . import *
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2018-2025 Jason Morley
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
#
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
# copies or substantial portions of the Software.
|
|
14
|
+
#
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
# SOFTWARE.
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
if not __package__:
|
|
27
|
+
# Make CLI runnable from source tree with
|
|
28
|
+
# python src/package
|
|
29
|
+
package_source_path = os.path.dirname(os.path.dirname(__file__))
|
|
30
|
+
sys.path.insert(0, package_source_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
from build_tools.build_tools import main
|
|
35
|
+
main()
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2018-2025 Jason Morley
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
#
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
# copies or substantial portions of the Software.
|
|
14
|
+
#
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
# SOFTWARE.
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import base64
|
|
25
|
+
import concurrent.futures
|
|
26
|
+
import contextlib
|
|
27
|
+
import datetime
|
|
28
|
+
import fnmatch
|
|
29
|
+
import functools
|
|
30
|
+
import glob
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import secrets
|
|
37
|
+
import subprocess
|
|
38
|
+
import shutil
|
|
39
|
+
import sys
|
|
40
|
+
import tempfile
|
|
41
|
+
import time
|
|
42
|
+
|
|
43
|
+
import requests
|
|
44
|
+
|
|
45
|
+
from xml.dom import minidom
|
|
46
|
+
|
|
47
|
+
verbose = '--verbose' in sys.argv[1:] or '-v' in sys.argv[1:]
|
|
48
|
+
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, format="[%(levelname)s] %(message)s")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
PROFILES_DIRECTORY = os.path.expanduser("~/Library/MobileDevice/Provisioning Profiles")
|
|
52
|
+
|
|
53
|
+
COMMANDS = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Command(object):
|
|
57
|
+
|
|
58
|
+
def __init__(self, name, help, arguments, callback):
|
|
59
|
+
self.name = name
|
|
60
|
+
self.help = help
|
|
61
|
+
self.arguments = arguments
|
|
62
|
+
self.callback = callback
|
|
63
|
+
|
|
64
|
+
class Argument(object):
|
|
65
|
+
|
|
66
|
+
def __init__(self, *args, **kwargs):
|
|
67
|
+
self.args = args
|
|
68
|
+
self.kwargs = kwargs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def command(name, help="", arguments=[]):
|
|
72
|
+
def wrapper(fn):
|
|
73
|
+
@functools.wraps(fn)
|
|
74
|
+
def inner(*args, **kwargs):
|
|
75
|
+
return fn(*args, **kwargs)
|
|
76
|
+
COMMANDS[name] = Command(name, help, arguments, inner)
|
|
77
|
+
return inner
|
|
78
|
+
return wrapper
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CommandParser(object):
|
|
82
|
+
|
|
83
|
+
def __init__(self, *args, **kwargs):
|
|
84
|
+
self.parser = argparse.ArgumentParser(*args, **kwargs)
|
|
85
|
+
subparsers = self.parser.add_subparsers(help="command")
|
|
86
|
+
for name, command in COMMANDS.items():
|
|
87
|
+
subparser = subparsers.add_parser(command.name, help=command.help)
|
|
88
|
+
for argument in command.arguments:
|
|
89
|
+
subparser.add_argument(*(argument.args), **(argument.kwargs))
|
|
90
|
+
subparser.set_defaults(fn=command.callback)
|
|
91
|
+
|
|
92
|
+
def run(self):
|
|
93
|
+
options = self.parser.parse_args()
|
|
94
|
+
if 'fn' not in options:
|
|
95
|
+
logging.error("No command specified.")
|
|
96
|
+
exit(1)
|
|
97
|
+
options.fn(options)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def shasum(path):
|
|
101
|
+
sha256 = hashlib.sha256()
|
|
102
|
+
if os.path.isdir(path):
|
|
103
|
+
for f in sorted(listdir(path, include_hidden=False)):
|
|
104
|
+
sha256.update(shasum(os.path.join(path, f)).encode('utf-8'))
|
|
105
|
+
else:
|
|
106
|
+
with open(path, 'rb') as f:
|
|
107
|
+
while True:
|
|
108
|
+
data = f.read(65536)
|
|
109
|
+
if not data:
|
|
110
|
+
break
|
|
111
|
+
sha256.update(data)
|
|
112
|
+
return sha256.hexdigest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def list_keychains():
|
|
116
|
+
keychains = subprocess.check_output(["security", "list-keychains", "-d", "user"]).decode("utf-8").strip().split("\n")
|
|
117
|
+
keychains = [json.loads(keychain) for keychain in keychains] # list-keychains quotes the strings
|
|
118
|
+
return keychains
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_keychain(path, password):
|
|
122
|
+
subprocess.check_call(["security", "create-keychain", "-p", password, path])
|
|
123
|
+
subprocess.check_call(["security", "set-keychain-settings", "-lut", "21600", path])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def unlock_keychain(path, password):
|
|
127
|
+
subprocess.check_call(["security", "unlock-keychain", "-p", password, path])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def add_keychain(path):
|
|
131
|
+
subprocess.check_call(["security", "list-keychains", "-d", "user", "-s"] + list_keychains() + [path])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@command("create-keychain", help="safely create a temporary keychain", arguments=[
|
|
135
|
+
Argument("path", help="path at which to create the keychain"),
|
|
136
|
+
Argument("--password", "-p", action="store_true", default=False, help="read password from stdin")
|
|
137
|
+
])
|
|
138
|
+
def command_create_keychain(options):
|
|
139
|
+
path = os.path.abspath(options.path)
|
|
140
|
+
logging.info("Creating keychain '%s'...", path)
|
|
141
|
+
password = secrets.token_hex()
|
|
142
|
+
if options.password:
|
|
143
|
+
password = sys.stdin.read().strip()
|
|
144
|
+
create_keychain(path, password)
|
|
145
|
+
add_keychain(path)
|
|
146
|
+
unlock_keychain(path, password)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@command("delete-keychain", help="safely delete a temporary keychain removing it from the active set", arguments=[
|
|
150
|
+
Argument("path", help="path of the keychain to delete")
|
|
151
|
+
])
|
|
152
|
+
def command_delete_keychain(options):
|
|
153
|
+
path = os.path.abspath(options.path)
|
|
154
|
+
logging.info("Deleting keychain '%s'...", path)
|
|
155
|
+
subprocess.check_call(["security", "delete-keychain", path])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# TODO: Is this used anywhere?
|
|
159
|
+
@command("verify-notarized-zip", help="unpack a compressed Mac app and verify the notarization", arguments=[
|
|
160
|
+
Argument("path", help="path to the zip file to verify")
|
|
161
|
+
])
|
|
162
|
+
def command_verify_notarized_zip(options):
|
|
163
|
+
path = os.path.abspath(options.path)
|
|
164
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
165
|
+
subprocess.check_call(["unzip", "-d", directory, "-q", path])
|
|
166
|
+
app_path = glob.glob(directory + "/*.app")[0]
|
|
167
|
+
try:
|
|
168
|
+
result = subprocess.run(["spctl", "-a", "-v", app_path], capture_output=True)
|
|
169
|
+
result.check_returncode()
|
|
170
|
+
except subprocess.CalledProcessError as e:
|
|
171
|
+
logging.error(e.stderr.decode("utf-8").strip())
|
|
172
|
+
exit("Failed to verify bundle.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@command("notarize", help="notarize (and staple where appropriate) one or more macOS build artifacts for distribution", arguments=[
|
|
176
|
+
Argument("path", nargs="+", help="path to the app bundle or binary to notarize"),
|
|
177
|
+
Argument("--key", required=True, help="path of the App Store Connect API key (required)"),
|
|
178
|
+
Argument("--key-id", required=True, help="App Store Connect API key id (required)"),
|
|
179
|
+
Argument("--issuer", required=True, help="App Store Connect API key issuer id (required)"),
|
|
180
|
+
Argument("--log-directory", help="write a notarization log for each path to this directory"),
|
|
181
|
+
])
|
|
182
|
+
def command_notarize(options):
|
|
183
|
+
key_path = os.path.abspath(options.key)
|
|
184
|
+
log_directory = os.path.abspath(options.log_directory) if options.log_directory else None
|
|
185
|
+
if log_directory is not None:
|
|
186
|
+
os.makedirs(log_directory, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
paths = [os.path.abspath(path) for path in options.path]
|
|
189
|
+
|
|
190
|
+
def notarize_path(path):
|
|
191
|
+
log_path = None
|
|
192
|
+
if log_directory is not None:
|
|
193
|
+
log_path = os.path.join(log_directory, f"{os.path.basename(path)}-notarization-log.json")
|
|
194
|
+
notarize(path, key_path=key_path, key_id=options.key_id, issuer=options.issuer, log_path=log_path)
|
|
195
|
+
|
|
196
|
+
# Notarize in parallel since Apple's servers are slow.
|
|
197
|
+
errors = {}
|
|
198
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(paths)) as executor:
|
|
199
|
+
futures = {executor.submit(notarize_path, path): path for path in paths}
|
|
200
|
+
for future in concurrent.futures.as_completed(futures):
|
|
201
|
+
path = futures[future]
|
|
202
|
+
try:
|
|
203
|
+
future.result()
|
|
204
|
+
except Exception as e:
|
|
205
|
+
errors[path] = e
|
|
206
|
+
|
|
207
|
+
if errors:
|
|
208
|
+
for path, error in errors.items():
|
|
209
|
+
logging.error("Failed to notarize '%s': %s", path, error)
|
|
210
|
+
exit(f"Failed to notarize {len(errors)} of {len(paths)} artifact(s).")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def notarize(path, key_path, key_id, issuer, log_path=None):
|
|
214
|
+
|
|
215
|
+
# Verify the app signature before continuing.
|
|
216
|
+
logging.info("Verifying signature of '%s'...", path)
|
|
217
|
+
verify_signature(path)
|
|
218
|
+
|
|
219
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
220
|
+
|
|
221
|
+
# Compress the app for submission.
|
|
222
|
+
zip_path = os.path.join(temporary_directory, "release.zip")
|
|
223
|
+
app_directory, app_basename = os.path.split(path)
|
|
224
|
+
logging.info("Compressing '%s' to '%s'...", app_basename, zip_path)
|
|
225
|
+
with contextlib.chdir(app_directory):
|
|
226
|
+
subprocess.check_call([
|
|
227
|
+
"zip",
|
|
228
|
+
"--symlinks",
|
|
229
|
+
"-r",
|
|
230
|
+
zip_path,
|
|
231
|
+
app_basename,
|
|
232
|
+
])
|
|
233
|
+
|
|
234
|
+
# Notarize.
|
|
235
|
+
logging.info("Notarizing '%s'...", zip_path)
|
|
236
|
+
output = subprocess.check_output([
|
|
237
|
+
"xcrun", "notarytool",
|
|
238
|
+
"submit", zip_path,
|
|
239
|
+
"--key", key_path,
|
|
240
|
+
"--key-id", key_id,
|
|
241
|
+
"--issuer", issuer,
|
|
242
|
+
"--output-format", "json",
|
|
243
|
+
"--wait",
|
|
244
|
+
]).decode("utf-8")
|
|
245
|
+
response = json.loads(output)
|
|
246
|
+
|
|
247
|
+
# Download the log and write it to disk.
|
|
248
|
+
if log_path is not None:
|
|
249
|
+
logging.info("Fetching notarization log with id '%s'...", response["id"])
|
|
250
|
+
output = subprocess.check_output([
|
|
251
|
+
"xcrun", "notarytool", "log",
|
|
252
|
+
"--key", key_path,
|
|
253
|
+
"--key-id", key_id,
|
|
254
|
+
"--issuer", issuer,
|
|
255
|
+
response["id"],
|
|
256
|
+
]).decode("utf-8")
|
|
257
|
+
with open(log_path, "w") as fh:
|
|
258
|
+
fh.write(output)
|
|
259
|
+
|
|
260
|
+
# Check to see if we should continue.
|
|
261
|
+
if response["status"] != "Accepted":
|
|
262
|
+
raise RuntimeError(f"notarization status was '{response['status']}', expected 'Accepted'")
|
|
263
|
+
|
|
264
|
+
# Staple and validate bundles; this bakes the notarization into the app in case the device trying to run it can't do
|
|
265
|
+
# an online check with Apple's servers for some reason.
|
|
266
|
+
if os.path.isdir(path):
|
|
267
|
+
subprocess.check_call([
|
|
268
|
+
"xcrun", "stapler",
|
|
269
|
+
"staple", path,
|
|
270
|
+
])
|
|
271
|
+
subprocess.check_call([
|
|
272
|
+
"xcrun", "stapler",
|
|
273
|
+
"validate", path,
|
|
274
|
+
])
|
|
275
|
+
|
|
276
|
+
# Next up, we perform a belt-and-braces check that the app validates after stapling.
|
|
277
|
+
verify_signature(path)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def verify_signature(path):
|
|
281
|
+
subprocess.check_call([
|
|
282
|
+
"codesign", "--verify", "--deep", "--strict", "--verbose=2", path,
|
|
283
|
+
])
|
|
284
|
+
subprocess.check_call([
|
|
285
|
+
"codesign", "--display", "-vvv", path,
|
|
286
|
+
])
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@command("generate-build-number", help="synthesize a build number (YYmmddHHMM + 8 digit integer representation of a 6 digit Git SHA")
|
|
290
|
+
def command_generate_build_number(options):
|
|
291
|
+
utc_time = datetime.datetime.now(datetime.UTC)
|
|
292
|
+
# Unhelpfully, the '--short=length' option guarantees to give an object name _no shorter_ than the requested length
|
|
293
|
+
# will happily, on occasion, return one that's longer, meaning we have to limit this to 6 characters ourselves.
|
|
294
|
+
git_sha = subprocess.check_output(["git", "rev-parse", "--short=6", "HEAD"]).decode("utf-8").strip()[:6]
|
|
295
|
+
git_sha_int = int(git_sha, 16)
|
|
296
|
+
build_number = f"{utc_time.strftime('%y%m%d%H%M')}{git_sha_int:08}"
|
|
297
|
+
print(build_number)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@command("latest-github-release", help="get the URL for an asset from the latest GitHub release matching a pattern (respects `GITHUB_TOKEN` environment variable)", arguments=[
|
|
301
|
+
Argument("owner"),
|
|
302
|
+
Argument("repository"),
|
|
303
|
+
Argument("pattern"),
|
|
304
|
+
])
|
|
305
|
+
def command_latest_github_release(options):
|
|
306
|
+
|
|
307
|
+
# Default API headers.
|
|
308
|
+
headers = {
|
|
309
|
+
"Accept": "application/vnd.github+json",
|
|
310
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Use a GitHub token if it's present in the environment as this is likely to have fewer rate limits.
|
|
314
|
+
if "GITHUB_TOKEN" in os.environ:
|
|
315
|
+
headers["Authorization"] = f"Bearer {os.environ["GITHUB_TOKEN"]}"
|
|
316
|
+
|
|
317
|
+
# Fetch the required data with an exponential backoff (max 5m) if we hit a 403 rate limit.
|
|
318
|
+
attempt = 1
|
|
319
|
+
while True:
|
|
320
|
+
response = requests.get(f"https://api.github.com/repos/{options.owner}/{options.repository}/releases/latest", headers=headers)
|
|
321
|
+
if response.status_code == 200:
|
|
322
|
+
break
|
|
323
|
+
elif response.status_code == 403:
|
|
324
|
+
sleep_duration_s = min(300, 2 ** attempt)
|
|
325
|
+
logging.info(f"Waiting {sleep_duration_s}s for GitHub API rate limits...")
|
|
326
|
+
time.sleep(sleep_duration_s)
|
|
327
|
+
attempt += 1
|
|
328
|
+
continue
|
|
329
|
+
else:
|
|
330
|
+
response.raise_for_status()
|
|
331
|
+
|
|
332
|
+
# Check the assets for the requested pattern.
|
|
333
|
+
regex = re.compile(fnmatch.translate(options.pattern))
|
|
334
|
+
for asset in response.json()["assets"]:
|
|
335
|
+
if not regex.match(asset["name"]):
|
|
336
|
+
continue
|
|
337
|
+
print(asset["browser_download_url"])
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
exit(f"Failed to find asset with pattern '{options.pattern}'.")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@command("parse-build-number", help="parse a build nunmber to retrieve the date and Git SHA", arguments=[
|
|
344
|
+
Argument("build", help="build number to parse")
|
|
345
|
+
])
|
|
346
|
+
def command_synthesize_build_number(options):
|
|
347
|
+
date_string, sha_string = options.build[:10], options.build[10:]
|
|
348
|
+
date = datetime.datetime.strptime(date_string, "%y%m%d%H%M")
|
|
349
|
+
sha = "%06x" % int(sha_string)
|
|
350
|
+
print("%s (UTC)" % date)
|
|
351
|
+
print(sha)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@command("import-base64-certificate", help="import base64 encoded certificate to a specific keychain", arguments=[
|
|
355
|
+
Argument("path", help="path of the keychain to update"),
|
|
356
|
+
Argument("certificate", help="base64 encoded PKCS12 (.p12) certificate"),
|
|
357
|
+
Argument("--password", "-p", action="store_true", default=False, help="read password from stdin"),
|
|
358
|
+
])
|
|
359
|
+
def command_import_certificate(options):
|
|
360
|
+
|
|
361
|
+
path = os.path.abspath(options.path)
|
|
362
|
+
if options.password:
|
|
363
|
+
password = sys.stdin.read().strip()
|
|
364
|
+
certificate = base64.b64decode(options.certificate)
|
|
365
|
+
|
|
366
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
367
|
+
certificate_path = os.path.join(directory, "certificate.p12")
|
|
368
|
+
with open(certificate_path, "wb") as fh:
|
|
369
|
+
fh.write(certificate)
|
|
370
|
+
|
|
371
|
+
parameters = [
|
|
372
|
+
"security", "import",
|
|
373
|
+
certificate_path,
|
|
374
|
+
"-A",
|
|
375
|
+
"-t", "cert",
|
|
376
|
+
"-f", "pkcs12",
|
|
377
|
+
"-k", path]
|
|
378
|
+
|
|
379
|
+
if options.password:
|
|
380
|
+
parameters.extend(["-P", password])
|
|
381
|
+
|
|
382
|
+
subprocess.check_call(parameters)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@command("install-provisioning-profile", help="install provisioining profile for the current user", arguments=[
|
|
386
|
+
Argument("path", nargs="+", help="path of profile to install"),
|
|
387
|
+
])
|
|
388
|
+
def command_install_provisioning_profile(options):
|
|
389
|
+
for path in options.path:
|
|
390
|
+
path = os.path.abspath(path)
|
|
391
|
+
expression = re.compile(r'<plist version="1\.0">(.*)<\/plist>', re.MULTILINE | re.DOTALL)
|
|
392
|
+
with open(path, "rb") as fh:
|
|
393
|
+
contents = fh.read().decode('utf-8', 'ignore')
|
|
394
|
+
match = expression.search(contents)
|
|
395
|
+
dom = minidom.parseString(match.group(1))
|
|
396
|
+
root = dom.getElementsByTagName("dict")[0]
|
|
397
|
+
uuid = None
|
|
398
|
+
found_uuid_key = False
|
|
399
|
+
for child in root.childNodes:
|
|
400
|
+
if child.nodeName == "key" and child.childNodes[0].data == "UUID":
|
|
401
|
+
found_uuid_key = True
|
|
402
|
+
continue
|
|
403
|
+
elif child.nodeName == "string" and found_uuid_key:
|
|
404
|
+
uuid = child.childNodes[0].data
|
|
405
|
+
break
|
|
406
|
+
if uuid is None:
|
|
407
|
+
exit("Unable to determine profile UUID.")
|
|
408
|
+
_, ext = os.path.splitext(path)
|
|
409
|
+
destination_name = f"{uuid}{ext}"
|
|
410
|
+
destination_path = os.path.join(PROFILES_DIRECTORY, destination_name)
|
|
411
|
+
if not os.path.exists(PROFILES_DIRECTORY):
|
|
412
|
+
logging.info("Creating profiles directory...")
|
|
413
|
+
os.makedirs(PROFILES_DIRECTORY)
|
|
414
|
+
if os.path.exists(destination_path):
|
|
415
|
+
logging.info("Provisioning profile '%s' already exists.", destination_name)
|
|
416
|
+
return
|
|
417
|
+
logging.info("Installing profile '%s' to '%s'...", os.path.basename(path), destination_path)
|
|
418
|
+
shutil.copy(path, destination_path)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@command("add-artifact", help="add an artifact to the artifact manifest", arguments=[
|
|
422
|
+
|
|
423
|
+
Argument("manifest", help="manifest to create or update"),
|
|
424
|
+
|
|
425
|
+
Argument("--project", required=True, help="id of the project to add; should be consistent across all artifacts for a specific project"),
|
|
426
|
+
Argument("--version", required=True, help="version of the project"),
|
|
427
|
+
Argument("--build-number", required=True, help="build number of the project"),
|
|
428
|
+
|
|
429
|
+
Argument("--name", help="filename of the asset; inferred from the path if not provided"),
|
|
430
|
+
Argument("--path", required=True, help="path to the artifact (relative or absolute); in the case of GitHub releases this should be the assset filename"),
|
|
431
|
+
Argument("--format", required=True, choices=["deb", "pkg", "zip"], help="artifact format"),
|
|
432
|
+
Argument("--git-sha", required=True, help="git sha associated with the artifact"),
|
|
433
|
+
|
|
434
|
+
Argument("--supports-os", required=True, choices=["macos", "debian", "ubuntu"], help="supported os"),
|
|
435
|
+
Argument("--supports-version", required=True, help="supported os version (e.g., 26, 24.04, etc)"),
|
|
436
|
+
Argument("--supports-codename", required=True, help="supported os codename (e.g., tahoe, noble, etc); repeat the os version if not relevant"),
|
|
437
|
+
Argument("--supports-architecture", required=True, choices=["arm64", "aarch64", "x86_64", "amd64"], action="append", default=[], help="supported os architecture (specify one-or-more)"),
|
|
438
|
+
])
|
|
439
|
+
def command_add_artifact(options):
|
|
440
|
+
manifest_path = os.path.abspath(options.manifest)
|
|
441
|
+
artifact_path = os.path.abspath(options.path)
|
|
442
|
+
|
|
443
|
+
# Load any existing manifest.
|
|
444
|
+
manifest = {
|
|
445
|
+
"version": 1,
|
|
446
|
+
"artifacts": [],
|
|
447
|
+
}
|
|
448
|
+
if os.path.exists(manifest_path):
|
|
449
|
+
with open(manifest_path, "r") as fh:
|
|
450
|
+
manifest = json.load(fh)
|
|
451
|
+
|
|
452
|
+
if "version" not in manifest or manifest["version"] != 1:
|
|
453
|
+
exit("Unsupported manifest version.")
|
|
454
|
+
|
|
455
|
+
name = options.name if options.name else os.path.basename(options.path)
|
|
456
|
+
|
|
457
|
+
sha256 = shasum(artifact_path)
|
|
458
|
+
|
|
459
|
+
supports = []
|
|
460
|
+
for architecture in options.supports_architecture:
|
|
461
|
+
supports.append({
|
|
462
|
+
"os": options.supports_os,
|
|
463
|
+
"version": options.supports_version,
|
|
464
|
+
"codename": options.supports_codename,
|
|
465
|
+
"architecture": architecture,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
# Look for an existing artifact to update with additional target information.
|
|
469
|
+
found_artifact = False
|
|
470
|
+
for artifact in manifest["artifacts"]:
|
|
471
|
+
if (artifact["project"] == options.project and
|
|
472
|
+
artifact["version"] == options.version and
|
|
473
|
+
artifact["build_number"] == options.build_number and
|
|
474
|
+
artifact["sha256"] == sha256 and
|
|
475
|
+
artifact["name"] == name and
|
|
476
|
+
artifact["path"] == options.path and
|
|
477
|
+
artifact["format"] == options.format and
|
|
478
|
+
artifact["git_sha"] == options.git_sha):
|
|
479
|
+
|
|
480
|
+
artifact["supports"].extend(supports)
|
|
481
|
+
found_artifact = True
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
# If we weren't able to find an artifact to update, then we create a new one.
|
|
485
|
+
if not found_artifact:
|
|
486
|
+
manifest["artifacts"].append({
|
|
487
|
+
"project": options.project,
|
|
488
|
+
"version": options.version,
|
|
489
|
+
"build_number": options.build_number,
|
|
490
|
+
|
|
491
|
+
"sha256": sha256,
|
|
492
|
+
"name": name,
|
|
493
|
+
"path": options.path,
|
|
494
|
+
"format": options.format,
|
|
495
|
+
"git_sha": options.git_sha,
|
|
496
|
+
|
|
497
|
+
"supports": supports,
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
# Write the updated manifest.
|
|
501
|
+
with open(manifest_path, "w") as fh:
|
|
502
|
+
json.dump(manifest, fh, indent=4)
|
|
503
|
+
fh.write("\n")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def main():
|
|
507
|
+
parser = CommandParser(description="Create and register a temporary keychain for development")
|
|
508
|
+
parser.run()
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == "__main__":
|
|
512
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inseven-build-tools
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Collection of convenience build tools
|
|
5
|
+
Author-email: Jason Morley <hello@jbmorley.co.uk>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests
|
|
9
|
+
|
|
10
|
+
# Build Tools
|
|
11
|
+
|
|
12
|
+
Collection of convenience build tools
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
build_tools/__init__.py
|
|
5
|
+
build_tools/__main__.py
|
|
6
|
+
build_tools/build_tools.py
|
|
7
|
+
inseven_build_tools.egg-info/PKG-INFO
|
|
8
|
+
inseven_build_tools.egg-info/SOURCES.txt
|
|
9
|
+
inseven_build_tools.egg-info/dependency_links.txt
|
|
10
|
+
inseven_build_tools.egg-info/entry_points.txt
|
|
11
|
+
inseven_build_tools.egg-info/requires.txt
|
|
12
|
+
inseven_build_tools.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
build_tools
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta:__legacy__"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "inseven-build-tools"
|
|
7
|
+
description = "Collection of convenience build tools"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "Jason Morley", email = "hello@jbmorley.co.uk"}
|
|
12
|
+
]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests",
|
|
15
|
+
]
|
|
16
|
+
dynamic = ["version"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
build-tools = "build_tools.build_tools:main"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2018-2025 Jason Morley
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
#
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
# copies or substantial portions of the Software.
|
|
14
|
+
#
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
# SOFTWARE.
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
from setuptools import setup
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
setup(version=os.environ["VERSION"] if "VERSION" in os.environ else "0.0.0")
|