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.
@@ -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,3 @@
1
+ # Build Tools
2
+
3
+ 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,2 @@
1
+ [console_scripts]
2
+ build-tools = build_tools.build_tools:main
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")