fastlane-plugin-bugsee 1.0.4 → 1.1.1

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.
data/BugseeAgent CHANGED
@@ -27,9 +27,2456 @@ import re
27
27
  import json
28
28
  import hashlib
29
29
  import shutil
30
+ import gzip
31
+ import plistlib
32
+ import socket
33
+ import time
34
+ import math
35
+ import platform
36
+ import struct
37
+ import uuid
30
38
  from optparse import OptionParser
31
39
  import fnmatch
32
40
 
41
+
42
+ # ──────────────────────────────────────────────────────────────────
43
+ # bugsee-cli resolver
44
+ #
45
+ # Symbol upload (dSYM) is handled by the bugsee-cli Rust binary —
46
+ # the same one the Android Gradle plugin uses. This agent shells out
47
+ # to it instead of hand-rolling zip + curl. The binary is
48
+ # auto-downloaded from download.bugsee.com on first use, SHA-256
49
+ # verified, and cached under ~/.bugsee/cli/<version>/<triple>/.
50
+ # Override via --cli-path (developer-local binary) or --cli-version
51
+ # (test a newer release). Environment fallbacks: BUGSEE_CLI_PATH,
52
+ # BUGSEE_CLI_VERSION.
53
+ #
54
+ # Build-info / dependency collection (the rest of this file below)
55
+ # is still done in Python here. That migration is deferred to the
56
+ # cross-platform build-info contributor SPI.
57
+ # ──────────────────────────────────────────────────────────────────
58
+
59
+ # Download FLOOR: the CLI version this agent installs on first use.
60
+ # After downloading it, the agent runs `bugsee-cli update --max-age 12h`
61
+ # (see `_maybe_self_update`), letting the CLI bump itself in place to
62
+ # the latest same-major release. Must be >= 0.6.0 — the first release that
63
+ # ships the `update` self-update command this agent relies on. Kept at the
64
+ # current release so a failed self-update (offline, blocked host) still leaves
65
+ # a CLI without the 0.6.0 NDK SYMBOL_TABLE silent-upload bug (bugsee-cli#61).
66
+ BUGSEE_CLI_DEFAULT_VERSION = "0.8.0"
67
+ BUGSEE_CLI_DOWNLOAD_BASE = "https://download.bugsee.com/cli"
68
+ # Strict version-string shape: SemVer-ish `<major>.<minor>.<patch>`
69
+ # optionally followed by `-<prerelease>`. This regex gates any value
70
+ # that flows into the download URL or the on-disk cache directory
71
+ # path, so a malicious BUGSEE_CLI_VERSION env var cannot inject `/` or
72
+ # `..` segments to escape the cache root or redirect downloads.
73
+ _BUGSEE_CLI_VERSION_RE = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$')
74
+
75
+ # ──────────────────────────────────────────────────────────────────
76
+ # CLI self-update (delegated to the CLI — shared contract across the
77
+ # fastlane / SDK / gradle / iOS producers)
78
+ #
79
+ # Version discovery is no longer hand-rolled here. After the agent has
80
+ # DOWNLOADED a binary it manages, it runs once per process:
81
+ #
82
+ # bugsee-cli update --max-age 12h
83
+ #
84
+ # The CLI owns everything: discovering the newest same-major
85
+ # (non-breaking) release, downloading + SHA-256-verifying it, and
86
+ # self-replacing the binary IN PLACE. `--max-age 12h` makes it throttle
87
+ # internally (a last-check timestamp next to the binary; no-op/no-network
88
+ # when fresh) and best-effort (any failure → exit 0). We only self-update
89
+ # a binary we MANAGE (one we auto-downloaded into ~/.bugsee/cli/...),
90
+ # never a PATH / explicit BUGSEE_CLI_PATH one — those may be read-only or
91
+ # shared and must not be mutated. Disable via BUGSEE_CLI_AUTO_UPDATE.
92
+ # ──────────────────────────────────────────────────────────────────
93
+
94
+ # `--max-age` value handed to `bugsee-cli update`; the CLI throttles its
95
+ # own network check to this freshness window.
96
+ _BUGSEE_CLI_SELF_UPDATE_MAX_AGE = "12h"
97
+ # Generous cap for the self-update subprocess. The CLI is best-effort, so
98
+ # a hang past this is swallowed (TimeoutExpired) and the build proceeds.
99
+ _BUGSEE_CLI_SELF_UPDATE_TIMEOUT_SECONDS = 120
100
+
101
+ # Process-wide guard so the self-update runs at most once per build phase.
102
+ _self_update_done = False
103
+
104
+
105
+ def _bugsee_cli_auto_update_enabled():
106
+ """Auto-update is ON unless BUGSEE_CLI_AUTO_UPDATE is an explicit
107
+ off token (`0` / `false` / `no` / `off`, case-insensitive)."""
108
+ raw = (os.environ.get("BUGSEE_CLI_AUTO_UPDATE") or "").strip().lower()
109
+ return raw not in ("0", "false", "no", "off")
110
+
111
+
112
+ def _maybe_self_update(cli_path):
113
+ """Run `bugsee-cli update --max-age 12h` BEST-EFFORT on a binary the
114
+ agent MANAGES (i.e. one it auto-downloaded into ~/.bugsee/cli/...).
115
+
116
+ The CLI owns version discovery (newest same-major release), download
117
+ + SHA-256 verify, in-place self-replace, and its own ~12h throttle
118
+ (a last-check timestamp next to the binary; no-op/no-network when
119
+ fresh). This call therefore just fires the command and forgets: it
120
+ swallows EVERYTHING (timeout, non-zero exit, exec failure), NEVER
121
+ raises, and never prints to stdout. Memoized to run at most once per
122
+ process. Skipped when BUGSEE_CLI_AUTO_UPDATE is disabled."""
123
+ global _self_update_done
124
+ if _self_update_done:
125
+ return
126
+ _self_update_done = True
127
+ if not cli_path or not _bugsee_cli_auto_update_enabled():
128
+ return
129
+ try:
130
+ subprocess.run(
131
+ [cli_path, "update", "--max-age",
132
+ _BUGSEE_CLI_SELF_UPDATE_MAX_AGE],
133
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
134
+ timeout=_BUGSEE_CLI_SELF_UPDATE_TIMEOUT_SECONDS, check=False,
135
+ )
136
+ except Exception:
137
+ # Best-effort: a hung / failing / missing CLI must never break or
138
+ # slow the build. Ignore the return code and any exception
139
+ # (TimeoutExpired included).
140
+ pass
141
+
142
+
143
+ # Exception tuple every CLI-shelling helper catches. Pinned in one
144
+ # place so all of `_resolve_*_via_cli` / `_collect_*_via_cli` /
145
+ # `_parse_*_via_cli` follow the same fallback policy:
146
+ # - OSError: subprocess exec failure, pipe close, broken file handle
147
+ # - ValueError: malformed JSON from CLI stdout
148
+ # - TypeError: defensive — subprocess.run mocked in tests can return
149
+ # MagicMock objects that explode in json.loads (this is the
150
+ # bite the dsym helper hit during TestParseDSYM hardening)
151
+ # - subprocess.CalledProcessError: a side_effect in test mocks that
152
+ # raise rather than return a fake CompletedProcess
153
+ _CLI_CATCHALL_EXCEPTIONS = (
154
+ OSError,
155
+ ValueError,
156
+ TypeError,
157
+ subprocess.CalledProcessError,
158
+ # TimeoutExpired: the CLI hung past its `subprocess.run(timeout=...)`
159
+ # cap. Treat as a soft failure so the fallback (in-process Python)
160
+ # path runs — a deadlocked CLI, SIGSTOP'd process, NFS-mounted
161
+ # binary stalling on first run, or an OS sandbox prompt waiting
162
+ # for user interaction would otherwise crash the build.
163
+ subprocess.TimeoutExpired,
164
+ )
165
+
166
+
167
+ def detectHostTriple():
168
+ """Map current OS+arch to a Rust target triple the CLI is published for.
169
+ Returns None for unsupported combinations (Linux musl, Windows ARM64, etc.).
170
+ """
171
+ system = platform.system().lower()
172
+ machine = platform.machine().lower()
173
+ is_arm64 = machine in ("arm64", "aarch64")
174
+ is_amd64 = machine in ("x86_64", "amd64")
175
+ if system == "darwin":
176
+ if is_arm64:
177
+ return "aarch64-apple-darwin"
178
+ if is_amd64:
179
+ return "x86_64-apple-darwin"
180
+ elif system == "linux":
181
+ if is_arm64:
182
+ return "aarch64-unknown-linux-gnu"
183
+ if is_amd64:
184
+ return "x86_64-unknown-linux-gnu"
185
+ elif system.startswith("win") or system == "windows":
186
+ if is_amd64:
187
+ return "x86_64-pc-windows-msvc"
188
+ return None
189
+
190
+
191
+ _resolveCli_cache = {}
192
+
193
+
194
+ def resolveCli(cliPath=None, cliVersion=None):
195
+ """Return path to a usable bugsee-cli binary, or None.
196
+ Precedence: explicit cliPath > BUGSEE_CLI_PATH env > auto-download.
197
+ Returning None is a soft failure — caller should fall back / skip.
198
+
199
+ Result is memoized by `(cliPath, cliVersion, BUGSEE_CLI_PATH env,
200
+ BUGSEE_CLI_VERSION env)` for the lifetime of the process. The
201
+ plugin invokes this once per CLI helper (8+ times per build) and
202
+ the resolve path includes filesystem walks (cache hit check,
203
+ network-side artefact-existence check) — caching the first
204
+ success/failure avoids the per-call cost. The env vars are part
205
+ of the key because a CI step could (in principle) flip them mid-
206
+ run; the cliPath/cliVersion args ARE typically constant per call
207
+ site but vary across helpers, so include them too.
208
+ """
209
+ cache_key = (
210
+ cliPath,
211
+ cliVersion,
212
+ os.environ.get("BUGSEE_CLI_PATH"),
213
+ os.environ.get("BUGSEE_CLI_VERSION"),
214
+ )
215
+ if cache_key in _resolveCli_cache:
216
+ return _resolveCli_cache[cache_key]
217
+ result = _resolveCli_uncached(cliPath, cliVersion)
218
+ # ONLY cache successful resolutions. A None result means the
219
+ # network was down, the SHA mismatched, or the host was unsupported
220
+ # on THIS call — but a subsequent call (after network comes up,
221
+ # after a retry, after the user installs the binary manually mid-
222
+ # build) may succeed. Caching the None would short-circuit every
223
+ # subsequent helper to the in-process fallback for the rest of
224
+ # the process lifetime.
225
+ if result is not None:
226
+ _resolveCli_cache[cache_key] = result
227
+ return result
228
+
229
+
230
+ def _resolveCli_uncached(cliPath=None, cliVersion=None):
231
+ explicit = cliPath or os.environ.get("BUGSEE_CLI_PATH")
232
+ if explicit:
233
+ if os.path.isfile(explicit) and os.access(explicit, os.X_OK):
234
+ return explicit
235
+ print("Bugsee: --cli-path %s is not an executable file; trying auto-download" % explicit)
236
+
237
+ # An explicit version (arg or env) is honoured EXACTLY. Otherwise we
238
+ # download the pinned floor; version discovery (bumping to the latest
239
+ # same-major release) is delegated to the CLI's own `update` command
240
+ # via `_maybe_self_update` after the download succeeds — the agent no
241
+ # longer hand-rolls it.
242
+ explicit_version = cliVersion or os.environ.get("BUGSEE_CLI_VERSION")
243
+ version = explicit_version or BUGSEE_CLI_DEFAULT_VERSION
244
+ # Strict version-string validation BEFORE the value flows into the
245
+ # download URL and the on-disk cache path. Without this, anyone who
246
+ # can set BUGSEE_CLI_VERSION (a malicious CI step, a poisoned .env,
247
+ # a Gemfile post_install) could land the cached binary at e.g.
248
+ # `~/.bugsee/cli/../../malicious_dir/<triple>/bugsee-cli` (path
249
+ # traversal) or redirect the download to a sibling release URL
250
+ # via injected path segments.
251
+ if not _BUGSEE_CLI_VERSION_RE.fullmatch(version):
252
+ print("Bugsee: rejecting BUGSEE_CLI_VERSION=%r — must match X.Y.Z[-prerelease]" % version)
253
+ return None
254
+ triple = detectHostTriple()
255
+ if triple is None:
256
+ print("Bugsee: no published bugsee-cli for host %s/%s; skipping CLI upload" % (
257
+ platform.system(), platform.machine()))
258
+ return None
259
+
260
+ cache_root = os.path.expanduser(os.path.join("~/.bugsee/cli", version, triple))
261
+ binary_name = "bugsee-cli.exe" if "windows" in triple else "bugsee-cli"
262
+ cached = os.path.join(cache_root, binary_name)
263
+
264
+ # Cache hit — the binary was auto-downloaded on a prior run and is one
265
+ # we manage, so it's eligible for self-update.
266
+ if os.path.isfile(cached) and os.access(cached, os.X_OK):
267
+ _maybe_self_update(cached)
268
+ return cached
269
+
270
+ try:
271
+ os.makedirs(cache_root, exist_ok=True)
272
+ _downloadCli(version, triple, cache_root)
273
+ except Exception as e:
274
+ print("Bugsee: failed to download bugsee-cli v%s for %s: %s" % (version, triple, e))
275
+ return None
276
+
277
+ if os.path.isfile(cached) and os.access(cached, os.X_OK):
278
+ # Freshly downloaded → a binary we manage; let the CLI bump it in
279
+ # place to the latest same-major release (best-effort, once per
280
+ # process). Never run on a PATH / BUGSEE_CLI_PATH binary.
281
+ _maybe_self_update(cached)
282
+ return cached
283
+ return None
284
+
285
+
286
+ def _downloadCli(version, triple, cacheDir):
287
+ """Fetch tarball + SHA-256 sidecar, verify, extract via system tar."""
288
+ is_windows = "windows" in triple
289
+ ext = "zip" if is_windows else "tar.xz"
290
+ artifact = "bugsee-cli-%s.%s" % (triple, ext)
291
+ url = "%s/v%s/%s" % (BUGSEE_CLI_DOWNLOAD_BASE, version, artifact)
292
+ sha_url = url + ".sha256"
293
+
294
+ # Defence-in-depth: refuse to fetch from anywhere but the literal
295
+ # https://download.bugsee.com host. BUGSEE_CLI_DOWNLOAD_BASE is a
296
+ # module global an importer can shadow, and the URL composition
297
+ # accepts any string before the version segment. Pin both the
298
+ # scheme and the host before either urlopen so neither a hostile
299
+ # import nor a typo on the base const can redirect downloads to
300
+ # an attacker-controlled origin. The sidecar verifies tarball
301
+ # integrity, not origin authenticity — those are independent
302
+ # protections that must both hold.
303
+ parsed = urllib.parse.urlsplit(url)
304
+ if parsed.scheme != "https" or parsed.hostname != "download.bugsee.com":
305
+ raise ValueError(
306
+ "Refusing to download bugsee-cli from non-canonical origin %s "
307
+ "(scheme=%s host=%s); set BUGSEE_CLI_DOWNLOAD_BASE back to "
308
+ "https://download.bugsee.com/cli to restore."
309
+ % (url, parsed.scheme, parsed.hostname)
310
+ )
311
+
312
+ print("Bugsee: downloading bugsee-cli v%s for %s from %s" % (version, triple, url))
313
+
314
+ # SHA-256 sidecar format: `<hex> <filename>` or `<hex> *<filename>` or just `<hex>`.
315
+ with urllib.request.urlopen(sha_url) as resp:
316
+ expected_sha = resp.read().decode("utf-8").strip().split()[0]
317
+
318
+ tarball_path = os.path.join(cacheDir, artifact)
319
+ with urllib.request.urlopen(url) as resp:
320
+ with open(tarball_path, "wb") as f:
321
+ shutil.copyfileobj(resp, f)
322
+
323
+ h = hashlib.sha256()
324
+ with open(tarball_path, "rb") as f:
325
+ for chunk in iter(lambda: f.read(65536), b""):
326
+ h.update(chunk)
327
+ actual_sha = h.hexdigest()
328
+ if expected_sha.lower() != actual_sha.lower():
329
+ os.unlink(tarball_path)
330
+ raise IOError("SHA-256 mismatch for %s: expected %s, got %s" % (
331
+ artifact, expected_sha, actual_sha))
332
+
333
+ # System `tar` handles tar.xz on macOS/Linux and zip on Windows 10+ (bsdtar).
334
+ # `--strip-components=1` peels the `bugsee-cli-<triple>/` wrapper dir.
335
+ result = subprocess.run(
336
+ ["tar", "-xf", tarball_path, "-C", cacheDir, "--strip-components=1"],
337
+ capture_output=True, text=True)
338
+ if result.returncode != 0:
339
+ raise IOError("tar -xf %s failed (exit %d): %s" % (
340
+ artifact, result.returncode, result.stderr.strip()))
341
+
342
+ os.unlink(tarball_path)
343
+ binary_name = "bugsee-cli.exe" if is_windows else "bugsee-cli"
344
+ binary_path = os.path.join(cacheDir, binary_name)
345
+ if not os.path.isfile(binary_path):
346
+ raise IOError("extraction did not produce %s in %s" % (binary_name, cacheDir))
347
+ os.chmod(binary_path, 0o755)
348
+ print("Bugsee: bugsee-cli v%s installed at %s" % (version, binary_path))
349
+
350
+
351
+ def uploadDsymViaCli(cliBinary, dsymPath, appToken, endpoint, version, build):
352
+ """Exec bugsee-cli to upload one .dSYM bundle. Returns True on success."""
353
+ cmd = [
354
+ cliBinary,
355
+ "--endpoint", endpoint,
356
+ "--app-token", appToken,
357
+ "debug-files", "upload",
358
+ "--type", "dsym",
359
+ "--version", version or "",
360
+ "--build", build or "",
361
+ dsymPath,
362
+ ]
363
+ # Stream stderr/stdout straight through so the user sees CLI progress
364
+ # in the same terminal / Xcode log they'd see BugseeAgent output in.
365
+ try:
366
+ result = subprocess.run(cmd, check=False)
367
+ return result.returncode == 0
368
+ except Exception as e:
369
+ print("Bugsee: failed to exec bugsee-cli at %s: %s" % (cliBinary, e))
370
+ return False
371
+
372
+
373
+ def uploadMappingViaCli(cliBinary, mappingPath, appToken, endpoint,
374
+ version, build, uuid, iconPath=None):
375
+ """Exec bugsee-cli to upload one ProGuard / R8 mapping.txt for
376
+ Android. The fastlane-plugin-bugsee `upload_mapping_to_bugsee`
377
+ action resolves the UUID Ruby-side (either from the Bugsee Gradle
378
+ plugin's BugseeBuildIdResolveTask output, or by Ruby-side
379
+ synthesis matching the SDK's runtime fallback formula) and
380
+ passes it via `uuid`. The CLI's local UUID compute is bypassed.
381
+
382
+ Returns True on success.
383
+ """
384
+ cmd = [
385
+ cliBinary,
386
+ "--endpoint", endpoint,
387
+ "--app-token", appToken,
388
+ "debug-files", "upload",
389
+ "--type", "proguard",
390
+ "--version", version or "",
391
+ "--build", build or "",
392
+ "--uuid", uuid or "",
393
+ ]
394
+ if iconPath:
395
+ cmd.extend(["--icon", iconPath])
396
+ cmd.append(mappingPath)
397
+ try:
398
+ result = subprocess.run(cmd, check=False)
399
+ return result.returncode == 0
400
+ except Exception as e:
401
+ print("Bugsee: failed to exec bugsee-cli at %s: %s" % (cliBinary, e))
402
+ return False
403
+
404
+
405
+ # ──────────────────────────────────────────────────────────────────
406
+ # Build dependencies collection (parallel to the Android Gradle
407
+ # plugin's `DependencyCollector` — wire format must stay in lockstep
408
+ # with `BugseeBuildDependenciesPipelineRecipe` consumed by the
409
+ # Bugsee worker / appserver / viewer stack).
410
+ #
411
+ # Sources scanned, in order:
412
+ # 1. CocoaPods — Podfile.lock (full resolved tree with
413
+ # parent → child edges)
414
+ # 2. Swift Package Mgr — Package.resolved (flat list, no graph)
415
+ # 3. Carthage — Cartfile.resolved (flat list, no graph)
416
+ # 4. Vendored frames — `otool -L` on the linked product binary,
417
+ # filtered to @rpath / @executable_path
418
+ # entries (system frameworks excluded)
419
+ #
420
+ # Output: same shape the Android plugin emits — a JSON dictionary
421
+ # carrying `schema_version: 1` and a `dependencies` array of
422
+ # objects with keys: `id`, `group`, `name`, `version`, `direct`,
423
+ # `scope`, `type`, `parents`. iOS-specific mapping notes:
424
+ #
425
+ # - `group` is the empty string for every source iOS deps come
426
+ # from (Pods / SPM packages / Carthage / vendored frameworks
427
+ # don't carry the Maven group:name:version triple).
428
+ # - `id` is `"<type>:<group>:<name>"` — three colons total for
429
+ # iOS rows because `group` is empty, matching the format the
430
+ # viewer's identityOf and the worker's _identity functions
431
+ # produce. Subspecs (e.g. "Braintree/Card") keep the slash
432
+ # in `name`.
433
+ # - `type` is one of `library` (Pods + SPM + Carthage) or
434
+ # `file` (vendored frameworks discovered via otool). No
435
+ # `project` rows from iOS for now — local SPM packages are
436
+ # emitted as library-type with the package name; the path
437
+ # distinction isn't surfaced.
438
+ # - `scope` is left unset by default. CocoaPods has a
439
+ # `:configuration` qualifier on `pod` directives but that's a
440
+ # Podfile-time concept the lockfile doesn't surface.
441
+ # ──────────────────────────────────────────────────────────────────
442
+
443
+ # Wire-format schema version. Must equal the Android plugin's
444
+ # `DependencyPayloadSerializer.SCHEMA_VERSION` and be in the worker's
445
+ # `_SUPPORTED_SCHEMA_VERSIONS` set; any bump on either side requires
446
+ # a parallel bump here.
447
+ DEPENDENCIES_SCHEMA_VERSION = 1
448
+
449
+ # Maximum number of entries we keep before truncating — same cap
450
+ # the Android plugin uses (5000). The summary's `truncated` flag
451
+ # surfaces when the cap kicks in, and the worker uses it to gate
452
+ # diffability (truncated builds can't be diffed against each
453
+ # other since the lists aren't apples-to-apples).
454
+ DEPENDENCIES_MAX_COUNT = 5000
455
+
456
+
457
+ def _make_dep_id(dep_type, group, name):
458
+ """Canonical identity string `"<type>:<group>:<name>"`. The
459
+ triple must stay byte-for-byte identical with the Android
460
+ plugin's `DependencyEntry.makeId` and the worker's
461
+ `_make_identity` so cross-platform diff matching works."""
462
+ return "%s:%s:%s" % (dep_type, group, name)
463
+
464
+
465
+ def _strip_pod_version_paren(s):
466
+ """Strip the ` (version)` or ` (~> constraint)` suffix from a
467
+ CocoaPods reference. Returns just the bare name, e.g.
468
+ `"Braintree/Card (= 5.26.0)"` -> `"Braintree/Card"`.
469
+ Used for both DEPENDENCIES section entries and child references
470
+ inside the PODS section — both wear the same version-paren
471
+ suffix and we always want the underlying name as the lookup key.
472
+ """
473
+ paren_pos = s.find('(')
474
+ if paren_pos == -1:
475
+ return s.strip()
476
+ return s[:paren_pos].strip()
477
+
478
+
479
+ def _parse_podfile_lock(path):
480
+ """Parse a CocoaPods `Podfile.lock` and return a list of dep
481
+ entries with parent edges.
482
+
483
+ Strategy:
484
+ 1. Walk the PODS: section line-by-line tracking indent depth.
485
+ A 2-space-indent line is a top-level pod declaration of
486
+ shape `- Name (Version):` (the trailing colon is optional —
487
+ pods with no transitive children skip the colon).
488
+ A 4-space-indent line is a child of the last top-level pod
489
+ seen, of shape `- Name [(constraint)]`.
490
+ 2. Walk the DEPENDENCIES: section in the same indent style,
491
+ collecting the bare name (no constraint) of every entry
492
+ the user declared directly in their Podfile.
493
+ 3. Emit one entry per top-level pod. `direct` is true iff
494
+ the pod (or a subspec whose base name matches) appears in
495
+ the DEPENDENCIES set. `parents` is the list of `id`s of
496
+ pods that referenced this one as a child — built by
497
+ reverse-walking the children map.
498
+
499
+ The format is YAML-ish but we don't pull in PyYAML — single-file
500
+ distribution constraint, and the lockfile shape is rigid enough
501
+ that a hand-rolled parser is robust.
502
+ """
503
+ if not path or not os.path.isfile(path):
504
+ return []
505
+
506
+ with open(path, 'r', encoding='utf-8') as f:
507
+ lines = f.read().splitlines()
508
+
509
+ # Split into sections by the top-level uppercase headers
510
+ # `PODS:`, `DEPENDENCIES:`, `SPEC REPOS:`, etc. Section start is
511
+ # a line at column 0 ending in ':' (we ignore everything past
512
+ # the PODS / DEPENDENCIES sections — SPEC CHECKSUMS, etc.).
513
+ pods_lines = []
514
+ deps_lines = []
515
+ current = None
516
+ for line in lines:
517
+ if re.match(r'^[A-Z][A-Z _]+:\s*$', line):
518
+ header = line.rstrip(': ').strip()
519
+ if header == 'PODS':
520
+ current = pods_lines
521
+ elif header == 'DEPENDENCIES':
522
+ current = deps_lines
523
+ else:
524
+ current = None
525
+ continue
526
+ if current is not None:
527
+ current.append(line)
528
+
529
+ # PODS section parse: top-level pods and their immediate
530
+ # children. We don't recurse — CocoaPods's lockfile is flat
531
+ # past depth 2 (children are pod refs, not nested declarations).
532
+ pods = {} # name -> {"version": str, "children": [str]}
533
+ current_pod = None
534
+ pod_line_re = re.compile(r'^ - (.+?)(?::?)$')
535
+ child_line_re = re.compile(r'^ - (.+?)$')
536
+ name_version_re = re.compile(r'^(.+?)\s+\(([^)]+)\)\s*$')
537
+ for line in pods_lines:
538
+ if not line.strip():
539
+ continue
540
+ m = child_line_re.match(line)
541
+ if m and current_pod is not None:
542
+ child_ref = _strip_pod_version_paren(m.group(1))
543
+ pods[current_pod]["children"].append(child_ref)
544
+ continue
545
+ m = pod_line_re.match(line)
546
+ if m:
547
+ body = m.group(1)
548
+ nv = name_version_re.match(body)
549
+ if nv:
550
+ name = nv.group(1).strip()
551
+ version = nv.group(2).strip()
552
+ else:
553
+ # Pod with no version paren — defensive; shouldn't
554
+ # happen in a well-formed lockfile but we don't
555
+ # crash on it.
556
+ name = body.strip()
557
+ version = None
558
+ pods[name] = {"version": version, "children": []}
559
+ current_pod = name
560
+
561
+ # DEPENDENCIES section parse: just the bare names of pods the
562
+ # user declared directly in their Podfile.
563
+ direct_names = set()
564
+ dep_line_re = re.compile(r'^ - (.+?)$')
565
+ for line in deps_lines:
566
+ if not line.strip():
567
+ continue
568
+ m = dep_line_re.match(line)
569
+ if m:
570
+ direct_names.add(_strip_pod_version_paren(m.group(1)))
571
+
572
+ # Reverse-walk the children map to build the parents lookup:
573
+ # for each child reference, the owning top-level pod is a
574
+ # parent. A child reference can include a subspec slash (e.g.
575
+ # `Braintree/Card`) — we record the parent against the exact
576
+ # referenced name, so subspecs end up with parents pointing at
577
+ # the pods that pulled them in (typically the parent pod and
578
+ # any cross-pod users).
579
+ parents_by_name = {}
580
+ for owner_name, info in pods.items():
581
+ owner_id = _make_dep_id("library", "", owner_name)
582
+ for child_ref in info["children"]:
583
+ parents_by_name.setdefault(child_ref, []).append(owner_id)
584
+
585
+ # Emit one entry per top-level pod. A pod is "direct" iff its
586
+ # EXACT name appears in DEPENDENCIES. CocoaPods writes subspecs
587
+ # into DEPENDENCIES verbatim when the user named them
588
+ # explicitly (`pod 'Foo', :subspecs => ['Bar']` → `Foo/Bar`
589
+ # appears in DEPENDENCIES). When the user writes `pod 'Foo'`
590
+ # against an umbrella pod, only `Foo` is in DEPENDENCIES —
591
+ # subspecs are pulled in transitively as children of the
592
+ # umbrella, which is exactly how the user intended.
593
+ entries = []
594
+ for name, info in pods.items():
595
+ is_direct = name in direct_names
596
+ entries.append({
597
+ "id": _make_dep_id("library", "", name),
598
+ "group": "",
599
+ "name": name,
600
+ "version": info["version"],
601
+ "direct": is_direct,
602
+ "scope": None,
603
+ "type": "library",
604
+ "parents": list(parents_by_name.get(name, [])),
605
+ })
606
+
607
+ return entries
608
+
609
+
610
+ def _parse_package_resolved(path):
611
+ """Parse Swift Package Manager's `Package.resolved` (JSON).
612
+
613
+ Two formats exist in the wild:
614
+ - Xcode-managed: `{"object": {"pins": [...]}, "version": 1|2}`
615
+ - SPM CLI v2: `{"pins": [...], "version": 2}` (Xcode 14+
616
+ and `swift package` write this at the project root)
617
+
618
+ Both carry the same per-pin information: a package identity
619
+ (kind/location/name) plus a resolved version pin. The resolved
620
+ file does NOT carry the dependency graph — only the locked
621
+ list — so every entry comes out as `direct=true` with no
622
+ parents. Tree-view rendering on the viewer reduces to a flat
623
+ list for SPM-only builds, which is correct: we don't have the
624
+ information to do better.
625
+ """
626
+ if not path or not os.path.isfile(path):
627
+ return []
628
+
629
+ try:
630
+ with open(path, 'r', encoding='utf-8') as f:
631
+ data = json.load(f)
632
+ except (OSError, ValueError):
633
+ return []
634
+
635
+ pins = None
636
+ if isinstance(data, dict):
637
+ if isinstance(data.get('pins'), list):
638
+ pins = data['pins']
639
+ elif isinstance(data.get('object'), dict) \
640
+ and isinstance(data['object'].get('pins'), list):
641
+ pins = data['object']['pins']
642
+ if not pins:
643
+ return []
644
+
645
+ entries = []
646
+ for pin in pins:
647
+ if not isinstance(pin, dict):
648
+ continue
649
+ # Identity field name differs by format:
650
+ # - new format uses `identity` (lowercase package name)
651
+ # - legacy format uses `package` (capitalised display name)
652
+ # Prefer `identity` when present so the id is stable across
653
+ # Xcode versions writing the same package set.
654
+ name = pin.get('identity') or pin.get('package')
655
+ if not name:
656
+ continue
657
+ version = None
658
+ state = pin.get('state') or {}
659
+ if isinstance(state, dict):
660
+ # `version` for tagged pins; `branch` for branch
661
+ # tracking; `revision` for revision-locked pins. Prefer
662
+ # the most specific human-readable value.
663
+ version = state.get('version') or state.get('branch') or state.get('revision')
664
+ # SPM URL — `location` is the v2/v3 key, `repositoryURL`
665
+ # the v1 key. Load-bearing for OSV vuln lookups (OSV's
666
+ # SwiftURL ecosystem keys off this URL). The Rust CLI
667
+ # emits this field too (bugsee-cli/src/cli/ios_deps.rs's
668
+ # `parse_package_resolved`); the in-process Python
669
+ # fallback must match or the wire-shape silently diverges
670
+ # depending on which path produced the payload.
671
+ url = pin.get('location') or pin.get('repositoryURL')
672
+ entry = {
673
+ "id": _make_dep_id("library", "", name),
674
+ "group": "",
675
+ "name": name,
676
+ "version": version,
677
+ "direct": True,
678
+ "scope": None,
679
+ "type": "library",
680
+ "parents": [],
681
+ }
682
+ if url:
683
+ entry["url"] = url
684
+ entries.append(entry)
685
+ return entries
686
+
687
+
688
+ def _parse_cartfile_resolved(path):
689
+ """Parse a Carthage `Cartfile.resolved`. Each line has the
690
+ shape: `<source> "<repo>" "<version>"`. Examples:
691
+
692
+ github "ReactiveCocoa/ReactiveCocoa" "v2.3.1"
693
+ git "https://example.com/private.git" "1.0.0"
694
+ binary "https://example.com/MyBin.json" "1.0.0"
695
+
696
+ Emit one library entry per line. No graph info, so direct=true
697
+ and parents=[]. The repo path (e.g. `ReactiveCocoa/ReactiveCocoa`)
698
+ becomes the name verbatim — surfacing the slash keeps the
699
+ namespacing visible in the dashboard.
700
+ """
701
+ if not path or not os.path.isfile(path):
702
+ return []
703
+
704
+ try:
705
+ with open(path, 'r', encoding='utf-8') as f:
706
+ lines = f.read().splitlines()
707
+ except OSError:
708
+ return []
709
+
710
+ entries = []
711
+ # `<source> "repo" "version"` — tolerate single-quoted values
712
+ # too (Carthage's parser does).
713
+ line_re = re.compile(r'^\s*(\S+)\s+["\']([^"\']+)["\']\s+["\']([^"\']+)["\']\s*$')
714
+ for line in lines:
715
+ if not line.strip() or line.strip().startswith('#'):
716
+ continue
717
+ m = line_re.match(line)
718
+ if not m:
719
+ continue
720
+ name = m.group(2)
721
+ version = m.group(3)
722
+ entries.append({
723
+ "id": _make_dep_id("library", "", name),
724
+ "group": "",
725
+ "name": name,
726
+ "version": version,
727
+ "direct": True,
728
+ "scope": None,
729
+ "type": "library",
730
+ "parents": [],
731
+ })
732
+ return entries
733
+
734
+
735
+ # Mach-O load paths emitted by `otool -L` that point at OS-owned
736
+ # frameworks. Stripped from the vendored-framework scan since they
737
+ # aren't user dependencies and would just bloat the list.
738
+ _SYSTEM_DYLIB_PREFIXES = (
739
+ '/usr/lib/',
740
+ '/System/Library/',
741
+ '/Library/Frameworks/',
742
+ )
743
+
744
+
745
+ def _parse_vendored_frameworks(binary_path):
746
+ """Run `otool -L` on the linked product binary and return a
747
+ list of dep entries for embedded / vendored frameworks. Lines
748
+ whose load path starts with `@rpath/`, `@executable_path/`, or
749
+ `@loader_path/` are the embedded frameworks shipped inside
750
+ the .app bundle; everything else is either the OS or a Swift
751
+ toolchain dylib — neither is a user dependency.
752
+
753
+ Each entry is `type="file"`, direct=true, no parents. The
754
+ `name` is the framework's basename (e.g. `Bugsee.framework`)
755
+ rather than the full @rpath path — the user-recognisable form.
756
+ """
757
+ if not binary_path or not os.path.isfile(binary_path):
758
+ return []
759
+
760
+ try:
761
+ out = subprocess.run(
762
+ ['/usr/bin/otool', '-L', binary_path],
763
+ capture_output=True, text=True, check=True,
764
+ ).stdout
765
+ except (subprocess.CalledProcessError, FileNotFoundError):
766
+ return []
767
+
768
+ seen = set()
769
+ entries = []
770
+ # Sample line: `\t@rpath/Bugsee.framework/Bugsee (compatibility version 1.0.0, current version 1.0.0)`
771
+ line_re = re.compile(r'^\s*([^\s]+)\s+\(compatibility version')
772
+ for raw in out.splitlines():
773
+ m = line_re.match(raw)
774
+ if not m:
775
+ continue
776
+ load_path = m.group(1)
777
+ if load_path.startswith(_SYSTEM_DYLIB_PREFIXES):
778
+ continue
779
+ if not (load_path.startswith('@rpath/')
780
+ or load_path.startswith('@executable_path/')
781
+ or load_path.startswith('@loader_path/')):
782
+ continue
783
+ # Extract the framework name: the `.framework/X` suffix
784
+ # carries Mach-O's binary slice name; the directory above
785
+ # it is the user-facing framework. Falls back to the
786
+ # basename if the path doesn't carry a `.framework/` token.
787
+ framework_match = re.search(r'/([^/]+\.framework)/', load_path)
788
+ if framework_match:
789
+ name = framework_match.group(1)
790
+ else:
791
+ name = os.path.basename(load_path)
792
+ if name in seen:
793
+ continue
794
+ seen.add(name)
795
+ entries.append({
796
+ "id": _make_dep_id("file", "", name),
797
+ "group": "",
798
+ "name": name,
799
+ "version": None,
800
+ "direct": True,
801
+ "scope": None,
802
+ "type": "file",
803
+ "parents": [],
804
+ })
805
+ return entries
806
+
807
+
808
+ def _merge_dep_entries(*sources):
809
+ """Combine multiple dep-entry lists into one. Deduplication
810
+ is field-wise — first-seen entry survives, but later sources
811
+ can graft on missing `url` / `version` / `parents` and OR-merge
812
+ the `direct` flag.
813
+
814
+ Why field-wise instead of pure first-source-wins: the parsers
815
+ populate these fields asymmetrically. CocoaPods walks the PODS
816
+ graph and emits `direct: false` + `parents: ["<umbrella>"]`
817
+ for transitive subspecs; SPM emits `direct: true` +
818
+ `parents: []` because `Package.resolved` only records resolved
819
+ pins, not the dependency graph; SPM ALSO carries the upstream
820
+ `url` (load-bearing for OSV vuln lookups). Wholesale-replacing
821
+ the existing record on url-preference would wipe the CocoaPods
822
+ graph evidence; ignoring url-bearing later sources would lose
823
+ the upstream URL. Mirrors the Rust CLI's `merge_dep_entries`
824
+ in `bugsee-cli/src/cli/ios_deps.rs`.
825
+
826
+ Returns a `(entries, truncated)` pair, capped at
827
+ [DEPENDENCIES_MAX_COUNT]. The truncated flag drives the
828
+ summary's same-name field and ultimately the worker's
829
+ compatibility check (truncated lists aren't diff-comparable).
830
+ """
831
+ by_id = {}
832
+ insertion_order = []
833
+ for source in sources:
834
+ for entry in source:
835
+ eid = entry["id"]
836
+ if eid not in by_id:
837
+ # Defensive copy so later same-id mutations don't
838
+ # bleed back into the caller's source list.
839
+ by_id[eid] = dict(entry)
840
+ insertion_order.append(eid)
841
+ continue
842
+ prev = by_id[eid]
843
+ # url: copy from incoming when previous has none.
844
+ if not prev.get("url") and entry.get("url"):
845
+ prev["url"] = entry["url"]
846
+ # direct: OR-merge — any source reaching the package
847
+ # directly is authoritative.
848
+ if entry.get("direct"):
849
+ prev["direct"] = True
850
+ # version: fill in when previous has none.
851
+ if prev.get("version") in (None, "") and entry.get("version"):
852
+ prev["version"] = entry["version"]
853
+ # parents: backfill when previous is empty. The first
854
+ # non-empty parents list wins so a follow-on source
855
+ # with `parents: []` can't wipe the graph evidence.
856
+ if not prev.get("parents") and entry.get("parents"):
857
+ prev["parents"] = list(entry["parents"])
858
+
859
+ deduped = [by_id[i] for i in insertion_order]
860
+ truncated = False
861
+ if len(deduped) > DEPENDENCIES_MAX_COUNT:
862
+ truncated = True
863
+ # Direct-prefer truncation: keep direct entries first.
864
+ # Stable sort preserves insertion order within each group.
865
+ deduped.sort(key=lambda e: 0 if e.get("direct") else 1)
866
+ deduped = deduped[:DEPENDENCIES_MAX_COUNT]
867
+
868
+ # Self-consistency pass: filter parents pointing at IDs that
869
+ # didn't make it into the emitted set (truncation eviction
870
+ # OR a malformed source listing a phantom parent). Same posture
871
+ # as the Android plugin's post-truncation cleanup.
872
+ kept = {e["id"] for e in deduped}
873
+ for e in deduped:
874
+ if e.get("parents"):
875
+ e["parents"] = [p for p in e["parents"] if p in kept]
876
+ return deduped, truncated
877
+
878
+
879
+ def _build_dependencies_payload(entries, truncated, scope_label,
880
+ include_selected_reason=False,
881
+ max_count=DEPENDENCIES_MAX_COUNT,
882
+ clock=None):
883
+ """Build the JSON-serialisable summary + blob pair. Shape
884
+ mirrors what the Android plugin produces (see
885
+ `DependencyPayloadSerializer.summaryJson` / `entriesJson`).
886
+
887
+ Returns a tuple `(summary_dict, blob_dict)`:
888
+ - `summary_dict` is the scalar summary sent inline on the
889
+ build registration POST body (under
890
+ `dependencies_summary`).
891
+ - `blob_dict` is the per-entry list serialised as JSON and
892
+ gzipped before PUT.
893
+ """
894
+ clock = clock or (lambda: int(time.time() * 1000))
895
+ total = len(entries)
896
+ direct = sum(1 for e in entries if e["direct"])
897
+ transitive = total - direct
898
+ by_type = {"library": 0, "project": 0, "file": 0}
899
+ for e in entries:
900
+ t = e["type"]
901
+ if t in by_type:
902
+ by_type[t] += 1
903
+
904
+ # ISO-8601 UTC with the trailing `Z` form — matches the
905
+ # Android plugin's `isoUtc` helper so the appserver's
906
+ # `new Date(collected_at)` parses identically.
907
+ epoch_ms = clock()
908
+ iso_utc = time.strftime('%Y-%m-%dT%H:%M:%SZ',
909
+ time.gmtime(epoch_ms / 1000))
910
+
911
+ summary = {
912
+ "total": total,
913
+ "direct": direct,
914
+ "transitive": transitive,
915
+ "by_type": by_type,
916
+ "truncated": truncated,
917
+ "collected_at": iso_utc,
918
+ "collection_config": {
919
+ "scope": scope_label,
920
+ "include_selected_reason": include_selected_reason,
921
+ "max_count": max_count,
922
+ },
923
+ }
924
+
925
+ # Per-entry blob. Omit `version` / `scope` when None, omit
926
+ # empty `parents` lists — same wire posture the Android
927
+ # serializer takes. Keeps the gzipped payload small for the
928
+ # common case (most pods don't have transitive children).
929
+ serialised = []
930
+ for e in entries:
931
+ item = {
932
+ "id": e["id"],
933
+ "group": e["group"],
934
+ "name": e["name"],
935
+ "direct": e["direct"],
936
+ "type": e["type"],
937
+ }
938
+ if e.get("version"):
939
+ item["version"] = e["version"]
940
+ if e.get("scope"):
941
+ item["scope"] = e["scope"]
942
+ if e.get("parents"):
943
+ item["parents"] = list(e["parents"])
944
+ # SPM `url` (load-bearing for OSV's SwiftURL vuln lookups).
945
+ # Mirrors the Rust `DepEntry::url` field's
946
+ # `#[serde(skip_serializing_if = "Option::is_none")]`. Without
947
+ # this, fastlane Python-fallback uploads would emit
948
+ # url-less blobs even when the CLI populated url, silently
949
+ # degrading vuln-scan coverage vs the SDK-side blob shape.
950
+ if e.get("url"):
951
+ item["url"] = e["url"]
952
+ serialised.append(item)
953
+
954
+ blob = {
955
+ "schema_version": DEPENDENCIES_SCHEMA_VERSION,
956
+ "dependencies": serialised,
957
+ }
958
+ return summary, blob
959
+
960
+
961
+ def _gzip_json_bytes(obj):
962
+ """Serialise `obj` to compact JSON and return gzipped bytes.
963
+ The blob is uploaded to a presigned S3 URL with
964
+ `Content-Encoding: gzip` on the worker side; the producer
965
+ PUT carries `application/octet-stream` (matching the Android
966
+ plugin's signing posture)."""
967
+ raw = json.dumps(obj, separators=(',', ':')).encode('utf-8')
968
+ return gzip.compress(raw, compresslevel=9)
969
+
970
+
971
+ # ──────────────────────────────────────────────────────────────────
972
+ # Build environment metadata (machine label, VCS, Xcode version)
973
+ #
974
+ # Ported from the SDK's tools.bundle/BugseeAgent. The wire shape
975
+ # matches the Android Gradle plugin's `BuildMachineResolver` and
976
+ # `VcsMetadataResolver` so a single build record can compare iOS
977
+ # and Android runs from the same CI runner.
978
+ #
979
+ # The appserver's `sanitizeVcs` / `sanitizeBuildMetadata`
980
+ # (appserver/code/components/app/builds/modules/build.vcs-helper.js)
981
+ # accepts these fields verbatim; the dashboard's build view reads
982
+ # `vcs.branch` / `vcs.commit_sha` / `vcs.pr_number` directly.
983
+ # ──────────────────────────────────────────────────────────────────
984
+
985
+ def _env_truthy(value):
986
+ """True when an env-var-style value is a conventional "on" token.
987
+ Matches the set used by the Android Gradle plugin (`"1"`, `"true"`,
988
+ `"yes"`, `"on"`, case-insensitive) so one CI config snippet
989
+ can enable a feature on both platforms."""
990
+ return (value or '').strip().lower() in ('1', 'true', 'yes', 'on')
991
+
992
+
993
+ def _set_if_present(out, key, value):
994
+ """Only write `key` to `out` when `value` is a non-empty string.
995
+ Keeps the JSON payload clean — the back-end distinguishes
996
+ "unknown" from "known empty" by field presence."""
997
+ if value and str(value).strip():
998
+ out[key] = str(value).strip()
999
+
1000
+
1001
+ def _local_hostname():
1002
+ """Short hostname or None. `socket.gethostname()` is the standard
1003
+ cross-platform lookup; it may throw on sandboxed hosts, hence
1004
+ the broad except."""
1005
+ try:
1006
+ host = socket.gethostname()
1007
+ return host if host else None
1008
+ except Exception:
1009
+ return None
1010
+
1011
+
1012
+ def _resolve_machine_label_via_cli():
1013
+ """Shell to `bugsee-cli build-env machine-label` and return the
1014
+ trimmed stdout. Option-C target."""
1015
+ cli_path_opt = None
1016
+ cli_version_opt = None
1017
+ try:
1018
+ cli_path_opt = getattr(options, 'cli_path', None)
1019
+ cli_version_opt = getattr(options, 'cli_version', None)
1020
+ except NameError:
1021
+ pass
1022
+ cli = resolveCli(cli_path_opt, cli_version_opt)
1023
+ if not cli:
1024
+ return None
1025
+ try:
1026
+ result = subprocess.run(
1027
+ [cli, "build-env", "machine-label"],
1028
+ capture_output=True, text=True, timeout=10, check=False,
1029
+ )
1030
+ if result.returncode != 0:
1031
+ return None
1032
+ out = (result.stdout or "").strip()
1033
+ return out if out else None
1034
+ except _CLI_CATCHALL_EXCEPTIONS:
1035
+ return None
1036
+
1037
+
1038
+ def resolve_machine_label():
1039
+ """Returns a `<provider>[:<detail>]` label describing where the
1040
+ build ran — mirrors the cascade in the Android plugin's
1041
+ `BuildMachineResolver` so the front-end can group iOS + Android
1042
+ builds from the same CI runner.
1043
+
1044
+ Prefers `bugsee-cli build-env machine-label` (single cross-
1045
+ language source of truth). Falls back to the in-process
1046
+ Python implementation below — pure env-based detection; no
1047
+ shelling out.
1048
+
1049
+ First positive provider signal wins. Falls back to the local
1050
+ hostname when nothing matches AND hostname lookup succeeds.
1051
+ Returns None as last resort (sandboxed / networkless hosts).
1052
+ """
1053
+ via_cli = _resolve_machine_label_via_cli()
1054
+ if via_cli is not None:
1055
+ return via_cli
1056
+
1057
+ env = os.environ
1058
+
1059
+ def with_detail(prefix, detail):
1060
+ detail = (detail or '').strip()
1061
+ return '%s:%s' % (prefix, detail) if detail else prefix
1062
+
1063
+ if _env_truthy(env.get('GITHUB_ACTIONS')):
1064
+ return with_detail('github-actions', env.get('RUNNER_NAME'))
1065
+ if _env_truthy(env.get('GITLAB_CI')):
1066
+ detail = ((env.get('CI_RUNNER_DESCRIPTION') or '').strip()
1067
+ or (env.get('CI_RUNNER_ID') or '').strip())
1068
+ return with_detail('gitlab-ci', detail)
1069
+ if (env.get('JENKINS_URL') or '').strip():
1070
+ return with_detail('jenkins', env.get('NODE_NAME'))
1071
+ if _env_truthy(env.get('CIRCLECI')):
1072
+ return with_detail('circleci', env.get('CIRCLE_NODE_INDEX'))
1073
+ if _env_truthy(env.get('BITRISE_IO')):
1074
+ return with_detail('bitrise', env.get('BITRISE_APP_SLUG'))
1075
+ if (env.get('TEAMCITY_VERSION') or '').strip():
1076
+ agent_name = ((env.get('AGENT_NAME') or '').strip()
1077
+ or (env.get('agent.name') or '').strip())
1078
+ return with_detail('teamcity', agent_name)
1079
+ # Xcode Cloud — Apple's own CI. `CI_WORKFLOW` is the canonical
1080
+ # presence signal; `CI_XCODEBUILD_ACTION` adds action context
1081
+ # (build / archive / test) when available.
1082
+ if ((env.get('CI_WORKFLOW') or '').strip()
1083
+ or _env_truthy(env.get('CI_XCODE_CLOUD'))):
1084
+ detail = ((env.get('CI_WORKFLOW') or '').strip()
1085
+ or (env.get('CI_XCODEBUILD_ACTION') or '').strip())
1086
+ return with_detail('xcode-cloud', detail)
1087
+ if _env_truthy(env.get('CI')):
1088
+ host = ((env.get('HOSTNAME') or '').strip()
1089
+ or _local_hostname() or '')
1090
+ return with_detail('ci', host)
1091
+
1092
+ return _local_hostname()
1093
+
1094
+
1095
+ def _run_git(working_dir, git_args):
1096
+ try:
1097
+ result = subprocess.run(
1098
+ ['/usr/bin/env', 'git'] + list(git_args),
1099
+ cwd=working_dir,
1100
+ capture_output=True, text=True, timeout=5,
1101
+ )
1102
+ if result.returncode != 0:
1103
+ return None
1104
+ return (result.stdout or '').strip() or None
1105
+ except Exception:
1106
+ return None
1107
+
1108
+
1109
+ def _resolve_git_fallback(working_dir):
1110
+ """Shell out to `git` for the basics. Returns {} if not a git
1111
+ working tree, or if `git` isn't on PATH (rare on dev macs, but
1112
+ possible in stripped-down CI containers)."""
1113
+ if not working_dir or not os.path.isdir(working_dir):
1114
+ return {}
1115
+ out = {}
1116
+ commit = _run_git(working_dir, ['rev-parse', 'HEAD'])
1117
+ if commit:
1118
+ out['commit_sha'] = commit
1119
+ branch = _run_git(working_dir, ['rev-parse', '--abbrev-ref', 'HEAD'])
1120
+ # Detached HEADs show up as "HEAD" — not a meaningful branch name.
1121
+ if branch and branch != 'HEAD':
1122
+ out['branch'] = branch
1123
+ return out
1124
+
1125
+
1126
+ def _resolve_vcs_metadata_via_cli(cli_binary, working_dir):
1127
+ """Shell to `bugsee-cli vcs-metadata` and parse its JSON output.
1128
+
1129
+ Part of the Option-C migration that moved VCS resolution to the
1130
+ Rust CLI as the single canonical implementation. The Python
1131
+ fallback below is preserved for the case where the CLI isn't
1132
+ available (older CLI versions that don't have the subcommand,
1133
+ a stripped-down env without auto-download, etc.).
1134
+
1135
+ Returns the parsed dict on success, or None on any failure
1136
+ (subprocess error, non-zero exit, malformed JSON). The caller
1137
+ falls back to the Python implementation on None.
1138
+ """
1139
+ if not cli_binary:
1140
+ return None
1141
+ try:
1142
+ result = subprocess.run(
1143
+ [cli_binary, "vcs-metadata", "--working-dir", working_dir or "."],
1144
+ capture_output=True, text=True, timeout=10, check=False,
1145
+ )
1146
+ if result.returncode != 0:
1147
+ return None
1148
+ out = (result.stdout or "").strip()
1149
+ if not out:
1150
+ return None
1151
+ return json.loads(out)
1152
+ except _CLI_CATCHALL_EXCEPTIONS:
1153
+ return None
1154
+
1155
+
1156
+ def resolve_vcs_metadata(working_dir):
1157
+ """Returns a dict shaped for the upload JSON's nested `vcs`
1158
+ sub-object. Matches what the Android Gradle plugin emits so the
1159
+ back-end persists both identically. Keys (any / all may be
1160
+ absent):
1161
+
1162
+ commit_sha, base_sha, branch, base_branch, pr_number,
1163
+ provider, repo
1164
+
1165
+ Resolution order — first provider with a positive signal wins:
1166
+ 1. GitHub Actions env (push + pull_request events).
1167
+ 2. GitLab CI env (push + merge_request events).
1168
+ 3. Bitbucket Pipelines env.
1169
+ 4. Git fallback — shell out to `git` in `working_dir` when
1170
+ none of the CI providers matched. Lets local dev archives
1171
+ still carry `commit_sha` / `branch` without needing CI
1172
+ context.
1173
+
1174
+ Implementation: prefers the Rust `bugsee-cli vcs-metadata`
1175
+ subcommand (the single cross-language source of truth, ported
1176
+ from this Python function — see
1177
+ `bugsee-cli/src/cli/vcs_metadata.rs`). Falls back to the
1178
+ in-process Python implementation below when the CLI isn't
1179
+ available so cold-start environments without the CLI cache
1180
+ still produce a usable result.
1181
+ """
1182
+ # Try the Rust CLI first — same algorithm, in the canonical
1183
+ # cross-language implementation. `options` is set by main() in
1184
+ # production but unset at import time (tests load the agent as
1185
+ # a module without ever invoking main()); the NameError catch
1186
+ # lets the resolver still invoke resolveCli with defaults so
1187
+ # the mock-based unit tests can exercise the CLI path.
1188
+ cli_path_opt = None
1189
+ cli_version_opt = None
1190
+ try:
1191
+ cli_path_opt = getattr(options, 'cli_path', None)
1192
+ cli_version_opt = getattr(options, 'cli_version', None)
1193
+ except NameError:
1194
+ pass
1195
+ cli = resolveCli(cli_path_opt, cli_version_opt)
1196
+ if cli:
1197
+ via_cli = _resolve_vcs_metadata_via_cli(cli, working_dir)
1198
+ if via_cli is not None:
1199
+ return via_cli
1200
+
1201
+ env = os.environ
1202
+
1203
+ # GitHub Actions ----------------------------------------------
1204
+ if _env_truthy(env.get('GITHUB_ACTIONS')):
1205
+ out = {'provider': 'github'}
1206
+ _set_if_present(out, 'commit_sha', env.get('GITHUB_SHA'))
1207
+ _set_if_present(out, 'repo', env.get('GITHUB_REPOSITORY'))
1208
+ if (env.get('GITHUB_EVENT_NAME') or '') == 'pull_request':
1209
+ _set_if_present(out, 'branch', env.get('GITHUB_HEAD_REF'))
1210
+ _set_if_present(out, 'base_branch', env.get('GITHUB_BASE_REF'))
1211
+ m = re.search(r'refs/pull/(\d+)/', env.get('GITHUB_REF') or '')
1212
+ if m:
1213
+ out['pr_number'] = int(m.group(1))
1214
+ else:
1215
+ # Push event: GITHUB_REF carries `refs/heads/<branch>` for
1216
+ # a branch push, `refs/tags/<tag>` for a tag push. Only
1217
+ # emit `branch` when the ref is actually a head ref. The
1218
+ # historical `.replace('refs/heads/', '', 1)` returned the
1219
+ # raw `refs/tags/v1.0.0` ref on tag pushes, which then
1220
+ # landed in the dashboard's branch column verbatim. Match
1221
+ # the Android Gradle plugin's canonical Kotlin resolver
1222
+ # (`removePrefix.takeIf { it != ref }`) and the Rust CLI
1223
+ # (bugsee-cli@cf1325f).
1224
+ ref = (env.get('GITHUB_REF') or '')
1225
+ if ref.startswith('refs/heads/'):
1226
+ _set_if_present(out, 'branch', ref[len('refs/heads/'):])
1227
+ return out
1228
+
1229
+ # GitLab CI ---------------------------------------------------
1230
+ if _env_truthy(env.get('GITLAB_CI')):
1231
+ out = {'provider': 'gitlab'}
1232
+ _set_if_present(out, 'commit_sha', env.get('CI_COMMIT_SHA'))
1233
+ _set_if_present(out, 'repo', env.get('CI_PROJECT_PATH'))
1234
+ # Merge-request pipelines expose different branch variables
1235
+ # than push pipelines. `CI_MERGE_REQUEST_IID` is the MR
1236
+ # number (the `_ID` variant is a global DB id and unusable
1237
+ # as a PR reference).
1238
+ if (env.get('CI_MERGE_REQUEST_IID') or '').strip():
1239
+ _set_if_present(out, 'branch',
1240
+ env.get('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME'))
1241
+ _set_if_present(out, 'base_branch',
1242
+ env.get('CI_MERGE_REQUEST_TARGET_BRANCH_NAME'))
1243
+ try:
1244
+ out['pr_number'] = int(env.get('CI_MERGE_REQUEST_IID'))
1245
+ except (TypeError, ValueError):
1246
+ pass
1247
+ else:
1248
+ # Branch vs tag pipeline distinction. GitLab CI sets:
1249
+ # - CI_COMMIT_BRANCH on branch pipelines (not tag).
1250
+ # - CI_COMMIT_TAG on tag pipelines (not branch).
1251
+ # - CI_COMMIT_REF_NAME is ALWAYS set; on a tag pipeline
1252
+ # it equals the tag name. Without the gate, tag
1253
+ # pipelines leaked the tag into the branch column.
1254
+ # Prefer CI_COMMIT_BRANCH; if absent and CI_COMMIT_TAG is
1255
+ # set, leave branch absent. Fall back to CI_COMMIT_REF_NAME
1256
+ # only for legacy GitLab (pre-12.6) that lacked the
1257
+ # specific markers.
1258
+ if (env.get('CI_COMMIT_BRANCH') or '').strip():
1259
+ _set_if_present(out, 'branch', env.get('CI_COMMIT_BRANCH'))
1260
+ elif (env.get('CI_COMMIT_TAG') or '').strip():
1261
+ pass # tag pipeline — branch stays absent
1262
+ else:
1263
+ _set_if_present(out, 'branch', env.get('CI_COMMIT_REF_NAME'))
1264
+ return out
1265
+
1266
+ # Bitbucket Pipelines -----------------------------------------
1267
+ if (env.get('BITBUCKET_BUILD_NUMBER') or '').strip():
1268
+ out = {'provider': 'bitbucket'}
1269
+ _set_if_present(out, 'commit_sha', env.get('BITBUCKET_COMMIT'))
1270
+ _set_if_present(out, 'repo',
1271
+ env.get('BITBUCKET_REPO_FULL_NAME')
1272
+ or env.get('BITBUCKET_REPO_SLUG'))
1273
+ _set_if_present(out, 'branch', env.get('BITBUCKET_BRANCH'))
1274
+ if (env.get('BITBUCKET_PR_ID') or '').strip():
1275
+ _set_if_present(out, 'base_branch',
1276
+ env.get('BITBUCKET_PR_DESTINATION_BRANCH'))
1277
+ try:
1278
+ out['pr_number'] = int(env.get('BITBUCKET_PR_ID'))
1279
+ except (TypeError, ValueError):
1280
+ pass
1281
+ return out
1282
+
1283
+ # Git fallback ------------------------------------------------
1284
+ # Covers local archives + any CI provider we don't specifically
1285
+ # recognise. Missing `provider` / `repo` is fine — the server
1286
+ # accepts partial payloads.
1287
+ return _resolve_git_fallback(working_dir)
1288
+
1289
+
1290
+ def _resolve_xcode_version_via_cli():
1291
+ """Shell to `bugsee-cli build-env xcode-version` and return the
1292
+ trimmed stdout. Option-C migration target — the Rust CLI owns
1293
+ the canonical resolver. Returns None on CLI absence /
1294
+ non-zero exit / empty output (the Python fallback below
1295
+ handles those cases)."""
1296
+ cli_path_opt = None
1297
+ cli_version_opt = None
1298
+ try:
1299
+ cli_path_opt = getattr(options, 'cli_path', None)
1300
+ cli_version_opt = getattr(options, 'cli_version', None)
1301
+ except NameError:
1302
+ pass
1303
+ cli = resolveCli(cli_path_opt, cli_version_opt)
1304
+ if not cli:
1305
+ return None
1306
+ try:
1307
+ result = subprocess.run(
1308
+ [cli, "build-env", "xcode-version"],
1309
+ capture_output=True, text=True, timeout=15, check=False,
1310
+ )
1311
+ if result.returncode != 0:
1312
+ return None
1313
+ out = (result.stdout or "").strip()
1314
+ return out if out else None
1315
+ except _CLI_CATCHALL_EXCEPTIONS:
1316
+ return None
1317
+
1318
+
1319
+ def resolve_xcode_version():
1320
+ """Returns the short Xcode version (e.g. `"16.2"`) or None. Tries
1321
+ `$XCODE_VERSION_ACTUAL` first (set in every Run Script env —
1322
+ numeric like `1620`, needs reformatting), then falls back to
1323
+ `xcodebuild -version` which is slow but ground-truth.
1324
+
1325
+ Prefers `bugsee-cli build-env xcode-version` when available
1326
+ (single cross-language source of truth, ported from this
1327
+ Python implementation — see `bugsee-cli/src/cli/build_env.rs`).
1328
+ Falls back to the in-process Python below when the CLI isn't
1329
+ installed."""
1330
+ via_cli = _resolve_xcode_version_via_cli()
1331
+ if via_cli is not None:
1332
+ return via_cli
1333
+
1334
+ actual = os.environ.get('XCODE_VERSION_ACTUAL')
1335
+ if actual and actual.isdigit():
1336
+ # "1620" → "16.2.0" ; "1543" → "15.4.3"
1337
+ parts = [actual[0:-2] or '0', actual[-2:-1] or '0',
1338
+ actual[-1:] or '0']
1339
+ return '.'.join(p.lstrip('0') or '0' for p in parts)
1340
+
1341
+ try:
1342
+ result = subprocess.run(
1343
+ ['/usr/bin/xcodebuild', '-version'],
1344
+ capture_output=True, text=True, timeout=10,
1345
+ )
1346
+ if result.returncode != 0:
1347
+ return None
1348
+ first_line = ((result.stdout or '').splitlines()[0].strip()
1349
+ if result.stdout else '')
1350
+ m = re.match(r'^Xcode\s+(\S+)', first_line)
1351
+ return m.group(1) if m else None
1352
+ except Exception:
1353
+ return None
1354
+
1355
+
1356
+ # ──────────────────────────────────────────────────────────────────
1357
+ # Xcode env + Info.plist helpers
1358
+ # ──────────────────────────────────────────────────────────────────
1359
+
1360
+ def _read_info_plist_via_cli(plist_path):
1361
+ """Shell to `bugsee-cli build-env read-plist <path>` and parse
1362
+ the JSON. Option-C migration target."""
1363
+ if not plist_path:
1364
+ return None
1365
+ cli_path_opt = None
1366
+ cli_version_opt = None
1367
+ try:
1368
+ cli_path_opt = getattr(options, 'cli_path', None)
1369
+ cli_version_opt = getattr(options, 'cli_version', None)
1370
+ except NameError:
1371
+ pass
1372
+ cli = resolveCli(cli_path_opt, cli_version_opt)
1373
+ if not cli:
1374
+ return None
1375
+ try:
1376
+ result = subprocess.run(
1377
+ [cli, "build-env", "read-plist", plist_path],
1378
+ capture_output=True, text=True, timeout=10, check=False,
1379
+ )
1380
+ if result.returncode != 0:
1381
+ return None
1382
+ out = (result.stdout or "").strip()
1383
+ if not out:
1384
+ return None
1385
+ return json.loads(out)
1386
+ except _CLI_CATCHALL_EXCEPTIONS:
1387
+ return None
1388
+
1389
+
1390
+ def _read_info_plist(plist_path):
1391
+ """Read the binary or XML Info.plist at the given path. Returns
1392
+ a dict on success or `{}` on any error — the caller treats
1393
+ missing fields as best-effort and falls back to env vars.
1394
+
1395
+ Prefers `bugsee-cli build-env read-plist` when available
1396
+ (single cross-language source of truth). Falls back to the
1397
+ in-process `plistlib.load` below.
1398
+
1399
+ Choke-point dict-coercion: every caller (`_collect_build_metadata`,
1400
+ `resolve_bundle_info_from_app`, `_extract_uuid_from_app`, …)
1401
+ indexes the return value as a dict. The CLI's `read-plist`
1402
+ returns whatever `json.loads(stdout)` produces with no shape
1403
+ validation, so a future CLI bug, version-skew, or exotic plist
1404
+ could surface as a list/string/number and crash every call site
1405
+ with `AttributeError`. Coerce non-dict returns to `{}` HERE so
1406
+ no caller needs its own isinstance guard."""
1407
+ via_cli = _read_info_plist_via_cli(plist_path)
1408
+ if via_cli is not None:
1409
+ return via_cli if isinstance(via_cli, dict) else {}
1410
+
1411
+ if not plist_path or not os.path.isfile(plist_path):
1412
+ return {}
1413
+ try:
1414
+ with open(plist_path, 'rb') as f:
1415
+ data = plistlib.load(f)
1416
+ return data if isinstance(data, dict) else {}
1417
+ except Exception:
1418
+ return {}
1419
+
1420
+
1421
+ def resolve_bundle_info_from_app(app_path):
1422
+ """Read the Info.plist inside a `.app` bundle and return
1423
+ `(bundle_id, version, build)`. Any field may be None when the
1424
+ plist lacks it or can't be read.
1425
+
1426
+ Mirrors the SDK BugseeAgent's
1427
+ `tools.bundle/BugseeAgent::resolve_bundle_info_from_app` so the
1428
+ fastlane artefact-upload flow ships the SAME baseline-lookup
1429
+ keys (`package_id`, `version`, `build`) the SDK does. Without
1430
+ matching keys the back-end's `/builds/baseline` lookup never
1431
+ finds a previous build for the same package, and the dashboard's
1432
+ size-trend chart silently shows no delta.
1433
+ """
1434
+ if not app_path or not os.path.isdir(app_path):
1435
+ return (None, None, None)
1436
+ plist_path = os.path.join(app_path, 'Info.plist')
1437
+ # `_read_info_plist` coerces non-dict CLI returns to `{}` at the
1438
+ # choke point, so an unguarded `.get()` is safe here.
1439
+ info = _read_info_plist(plist_path)
1440
+ return (
1441
+ info.get('CFBundleIdentifier'),
1442
+ info.get('CFBundleShortVersionString'),
1443
+ info.get('CFBundleVersion'),
1444
+ )
1445
+
1446
+
1447
+ # Arch preference order for fat-binary LC_UUID selection. arm64 wins
1448
+ # because it's the slice the runtime SDK reports on actual device
1449
+ # crashes; arm64e is M-series Macs; x86_64 covers Intel macs; the
1450
+ # simulator variants come last (only useful for simulator-only Debug
1451
+ # Build configs). Matches the SDK BugseeAgent's
1452
+ # `tools.bundle/BugseeAgent::_PREFERRED_MACHO_ARCHS` ordering exactly
1453
+ # so the fastlane and SDK producers emit the SAME uuid for the same
1454
+ # fat-binary build.
1455
+ _PREFERRED_MACHO_ARCHS = ('arm64', 'arm64e', 'x86_64',
1456
+ 'arm64-simulator', 'x86_64-simulator')
1457
+
1458
+
1459
+ def _load_macho_slices_via_cli(binary_path):
1460
+ """Shell to `bugsee-cli dsym slices <binary>` and return a
1461
+ `{arch: uuid}` dict, or None when the CLI is unavailable / its
1462
+ output can't be parsed.
1463
+
1464
+ Mirrors the SDK BugseeAgent's `_load_macho_slices_via_cli` so
1465
+ `_extract_uuid_from_app` can pick the preferred arch's UUID on
1466
+ fat binaries — same posture as the SDK side, which means the two
1467
+ producers emit byte-identical uuids for the same artefact."""
1468
+ if not binary_path:
1469
+ return None
1470
+ cli = shutil.which("bugsee-cli")
1471
+ if not cli:
1472
+ return None
1473
+ try:
1474
+ result = subprocess.run(
1475
+ [cli, "dsym", "slices", binary_path],
1476
+ capture_output=True, text=True, timeout=30, check=False,
1477
+ )
1478
+ if result.returncode != 0:
1479
+ return None
1480
+ out = (result.stdout or "").strip()
1481
+ if not out:
1482
+ return None
1483
+ data = json.loads(out)
1484
+ if not isinstance(data, list):
1485
+ return None
1486
+ except _CLI_CATCHALL_EXCEPTIONS:
1487
+ return None
1488
+ slices = {}
1489
+ for entry in data:
1490
+ if not isinstance(entry, dict):
1491
+ continue
1492
+ uuid_str = entry.get('uuid')
1493
+ arch = entry.get('arch')
1494
+ if not isinstance(uuid_str, str) or not isinstance(arch, str):
1495
+ continue
1496
+ # First-seen wins for duplicate arch entries — matches the
1497
+ # SDK side's behaviour (rare exotic case).
1498
+ if arch not in slices:
1499
+ slices[arch] = uuid_str
1500
+ return slices
1501
+
1502
+
1503
+ def _extract_uuid_from_app(app_path):
1504
+ """Extract the main executable's Mach-O `LC_UUID` from `app_path`
1505
+ and format it the way the iOS SDK reports it at runtime — 32
1506
+ lowercase hex chars, no dashes. Returns None on any failure
1507
+ (missing CFBundleExecutable, binary absent, CLI missing).
1508
+
1509
+ Prefers the arch-aware `bugsee-cli dsym slices <main_binary>`
1510
+ subcommand and picks the preferred-arch UUID via the cascade
1511
+ `arm64 → arm64e → x86_64 → arm64-simulator → x86_64-simulator →
1512
+ first reported`. Falls back to the flat `dsym uuid` subcommand
1513
+ only when slices returns None (older bugsee-cli versions that
1514
+ predate the slices subcommand, or when the CLI isn't on PATH).
1515
+
1516
+ Mirrors the SDK BugseeAgent's `get_main_executable_uuid` posture
1517
+ exactly so the fastlane producer path and the SDK producer path
1518
+ emit the SAME uuid for the same fat-binary build — without this
1519
+ cross-source consistency the back-end's `crash.uuid → build`
1520
+ join would only match on whichever producer happened to upload
1521
+ the build record.
1522
+
1523
+ The format matches `BGSCrashReport.m`'s LC_UUID reporting so
1524
+ the back-end can join `crash.uuid → build.uuid` deterministically.
1525
+ """
1526
+ if not app_path or not os.path.isdir(app_path):
1527
+ return None
1528
+ plist_path = os.path.join(app_path, 'Info.plist')
1529
+ # `_read_info_plist` returns `{}` for non-dict CLI output, so
1530
+ # `.get()` is safe without an isinstance guard.
1531
+ info = _read_info_plist(plist_path)
1532
+ executable_name = info.get('CFBundleExecutable')
1533
+ if not executable_name:
1534
+ return None
1535
+ binary_path = os.path.join(app_path, executable_name)
1536
+ if not os.path.isfile(binary_path):
1537
+ return None
1538
+ # Prefer the arch-aware `dsym slices` subcommand so a fat binary's
1539
+ # arm64 slice (the one the iOS runtime SDK reports at crash time)
1540
+ # wins over x86_64/simulator slices that may appear first in the
1541
+ # archive's load-command order. Mirrors the SDK BugseeAgent's
1542
+ # `get_main_executable_uuid` posture exactly — without this, the
1543
+ # fastlane producer path and the SDK producer path could emit
1544
+ # different uuids for the same fat-binary build.
1545
+ slices = _load_macho_slices_via_cli(binary_path)
1546
+ candidate_uuid = None
1547
+ if slices:
1548
+ for arch in _PREFERRED_MACHO_ARCHS:
1549
+ if arch in slices:
1550
+ candidate_uuid = slices[arch]
1551
+ break
1552
+ if candidate_uuid is None:
1553
+ # Exotic arch — take the first reported. Deterministic
1554
+ # because the dict preserved insertion order from the CLI
1555
+ # output (Python 3.7+).
1556
+ candidate_uuid = next(iter(slices.values()))
1557
+
1558
+ # Fallback to the flat `dsym uuid` subcommand (for older bugsee-cli
1559
+ # versions or when slices returned None — e.g. CLI not on PATH and
1560
+ # the helper short-circuited).
1561
+ if candidate_uuid is None:
1562
+ uuids = _parse_dsym_via_cli(binary_path)
1563
+ if uuids:
1564
+ candidate_uuid = uuids[0]
1565
+
1566
+ # Funnel through the shared choke-point helper so the canonical
1567
+ # shape contract (32 lowercase hex chars, no dashes, whitespace-
1568
+ # only → None) lives in one place. Makes a regression that flipped
1569
+ # the normalisation rule trivially visible — every caller flows
1570
+ # through `_normalise_build_uuid`.
1571
+ return _normalise_build_uuid(candidate_uuid)
1572
+
1573
+
1574
+ def _collect_build_metadata():
1575
+ """Read Xcode environment + Info.plist into the build-registration
1576
+ POST body shape. Mirrors the Android plugin's metadata block —
1577
+ the appserver / viewer consume the same field names regardless
1578
+ of platform.
1579
+
1580
+ Returns a tuple of `(body_dict, dwarf_uuid_or_none)`. The
1581
+ body's `uuid` carries the Mach-O image UUID extracted from the
1582
+ dSYM via dwarfdump; the value is also returned so the caller
1583
+ can pass it to the symbols-upload step without re-running
1584
+ dwarfdump.
1585
+ """
1586
+ build_dir = os.environ.get('TARGET_BUILD_DIR') or options.build_dir or ''
1587
+ info_path_rel = os.environ.get('INFOPLIST_PATH', '')
1588
+ info_path = os.path.join(build_dir, info_path_rel) if info_path_rel else ''
1589
+ info = _read_info_plist(info_path)
1590
+
1591
+ version = info.get('CFBundleShortVersionString') \
1592
+ or os.environ.get('MARKETING_VERSION') \
1593
+ or (options.version if options.version else None)
1594
+ build_number = info.get('CFBundleVersion') \
1595
+ or os.environ.get('CURRENT_PROJECT_VERSION') \
1596
+ or (options.build if options.build else None)
1597
+ bundle_id = info.get('CFBundleIdentifier') \
1598
+ or os.environ.get('PRODUCT_BUNDLE_IDENTIFIER')
1599
+
1600
+ body = {
1601
+ # Mach-O UUID is filled in by the caller (after dwarfdump).
1602
+ "uuid": None,
1603
+ "package_id": bundle_id,
1604
+ "version": str(version) if version is not None else None,
1605
+ "build": str(build_number) if build_number is not None else None,
1606
+ "build_configuration": os.environ.get('CONFIGURATION'),
1607
+ # iOS deploy format. The appserver's ALLOWED_BUILD_FORMATS
1608
+ # set includes 'ipa' alongside 'apk' / 'aab'.
1609
+ "format": "ipa",
1610
+ "has_mapping": False, # no Java-style ProGuard mapping on iOS
1611
+ "build_metadata": {
1612
+ "machine": {
1613
+ # `host` is the CI-provider-aware label (e.g.
1614
+ # `github-actions:Linux-X64`); falls back to
1615
+ # platform.node() for local-archive runs where no
1616
+ # provider env var is set. The richer label lets the
1617
+ # dashboard cluster runs by runner across platforms.
1618
+ "host": resolve_machine_label() or platform.node(),
1619
+ "os": platform.platform(),
1620
+ "arch": platform.machine(),
1621
+ },
1622
+ "build_system": {
1623
+ "type": "xcode",
1624
+ # `XCODE_VERSION_ACTUAL` is the numeric form Xcode
1625
+ # exports (`"1620"` for 16.2.0); the resolver
1626
+ # normalises to the dotted form the dashboard
1627
+ # displays. Falls back to `xcodebuild -version`.
1628
+ "version": resolve_xcode_version(),
1629
+ },
1630
+ "agent": {
1631
+ "name": "BugseeAgent",
1632
+ # Resolved relative to the script — keeps the value
1633
+ # honest even when the SDK ships multiple agent
1634
+ # versions in different distributions.
1635
+ "script": os.path.basename(__file__),
1636
+ },
1637
+ },
1638
+ }
1639
+
1640
+ # VCS metadata. Sibling field to `build_metadata` — matches the
1641
+ # Android Gradle plugin's wire shape and the appserver's
1642
+ # `sanitizeVcs` accept-list. Omit the field entirely when no
1643
+ # provider was detected AND the git fallback didn't surface a
1644
+ # commit (e.g. /tmp archive without a .git dir) — empty `vcs: {}`
1645
+ # records would still pass `sanitizeVcs` but pollute the dashboard.
1646
+ vcs = resolve_vcs_metadata(
1647
+ os.environ.get('SRCROOT')
1648
+ or os.environ.get('PROJECT_DIR')
1649
+ or os.getcwd()
1650
+ )
1651
+ if vcs:
1652
+ body["vcs"] = vcs
1653
+
1654
+ return body
1655
+
1656
+
1657
+ # -----------------------------------------------------------------
1658
+ # Artifact packaging (synthetic .ipa from `.app`)
1659
+ # -----------------------------------------------------------------
1660
+
1661
+ # File extensions that are already compressed; storing them with
1662
+ # DEFLATE wastes CPU without saving bytes. Mirrors the SDK
1663
+ # BugseeAgent's (`tools.bundle/BugseeAgent::_IPA_STORE_EXTENSIONS`)
1664
+ # and the Android Gradle plugin's `BundleZipNormalizer` set. Keep in
1665
+ # sync — divergence would make the byte-deterministic IPA dedup that
1666
+ # the back-end relies on break across producers.
1667
+ _IPA_STORE_EXTENSIONS = (
1668
+ # Image / video / audio
1669
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".heic",
1670
+ ".mp4", ".mov", ".m4v", ".avi", ".webm",
1671
+ ".mp3", ".m4a", ".aac", ".ogg", ".flac", ".wav",
1672
+ # Archive / asset-catalog / package formats
1673
+ ".zip", ".gz", ".bz2", ".xz", ".7z",
1674
+ ".car", # Asset catalog
1675
+ ".pdf",
1676
+ # Compiled fonts + binary glyph caches
1677
+ ".woff", ".woff2", ".otf", ".ttf",
1678
+ )
1679
+
1680
+ # MS-DOS date/time epoch is 1980-01-01; picking a fixed value gives
1681
+ # the zip a stable mtime across rebuilds so two archives of identical
1682
+ # bits produce byte-identical synthetic IPAs. Mirrors the SDK side
1683
+ # (`tools.bundle/BugseeAgent::_IPA_FIXED_MTIME`) and the Android
1684
+ # Gradle plugin's `DOS_EPOCH_LOCAL`.
1685
+ _IPA_FIXED_MTIME = (1980, 1, 1, 0, 0, 0)
1686
+
1687
+
1688
+ def package_app_as_ipa(app_path, output_ipa_path):
1689
+ """Zip `<App>.app` as `Payload/<App>.app/...` into an .ipa.
1690
+
1691
+ Byte-deterministic — entries sorted by name, mtimes pinned. Two
1692
+ runs over the same source tree produce hash-identical output, so
1693
+ the back-end can dedupe re-uploads by content sha. Compression
1694
+ method per-entry: STORE (no recompression) for already-compressed
1695
+ formats (PNG/JPG/MP4/.car/...), DEFLATE for plists / strings /
1696
+ Mach-O / etc. Same posture as the SDK BugseeAgent's
1697
+ `package_app_as_ipa` so artefacts produced by either producer
1698
+ are interchangeable on the back-end.
1699
+
1700
+ DOES NOT add iTunesMetadata.plist or code signing — neither is
1701
+ needed for size analysis (the back-end's `_analyze_bundle` walks
1702
+ `Payload/*.app` and runs the categorisation heuristics off the
1703
+ Mach-O + resource tree only).
1704
+ """
1705
+ with zipfile.ZipFile(output_ipa_path, 'w', zipfile.ZIP_DEFLATED) as zf:
1706
+ for root, dirs, files in os.walk(app_path):
1707
+ # Sort in place so `os.walk` traverses in a deterministic
1708
+ # order — filesystem enumeration is otherwise non-
1709
+ # deterministic (and differs between APFS / HFS+ / NFS).
1710
+ dirs.sort()
1711
+ files.sort()
1712
+ for f in files:
1713
+ src = os.path.join(root, f)
1714
+ if os.path.islink(src):
1715
+ # Real IPAs don't carry symlinks; zipfile can't
1716
+ # encode them; skip.
1717
+ continue
1718
+ rel = os.path.relpath(src, os.path.dirname(app_path))
1719
+ arcname = os.path.join('Payload', rel)
1720
+ method = (
1721
+ zipfile.ZIP_STORED
1722
+ if f.lower().endswith(_IPA_STORE_EXTENSIONS)
1723
+ else zipfile.ZIP_DEFLATED
1724
+ )
1725
+ info = zipfile.ZipInfo(filename=arcname, date_time=_IPA_FIXED_MTIME)
1726
+ info.compress_type = method
1727
+ # Preserve POSIX exec bits on the embedded Mach-O —
1728
+ # otherwise the archive encodes as plain data and the
1729
+ # back-end's executable-detection heuristics (e.g.
1730
+ # "is this the main binary?") lose a signal.
1731
+ try:
1732
+ st_mode = os.stat(src).st_mode
1733
+ info.external_attr = (st_mode & 0xFFFF) << 16
1734
+ except OSError:
1735
+ pass
1736
+ # Stream each file into the archive instead of
1737
+ # `zf.writestr(info, fp.read())` — the latter slurps
1738
+ # the entire source file into a transient bytes
1739
+ # buffer, doubling RSS for every embedded Mach-O. A
1740
+ # 300 MB main binary + 100 MB Swift stdlib + asset
1741
+ # catalogs would peak well over 1 GB on a CI runner
1742
+ # with 4 GB total. `zf.open(info, 'w')` returns a
1743
+ # stream-capable target; copy in 64 KB chunks.
1744
+ # `compress_type` MUST be set on the ZipInfo BEFORE
1745
+ # `zf.open` consumes it (already done above).
1746
+ with open(src, 'rb') as fp, zf.open(info, 'w') as dst:
1747
+ shutil.copyfileobj(fp, dst, length=65536)
1748
+ return output_ipa_path
1749
+
1750
+
1751
+ def _normalise_build_uuid(raw):
1752
+ """Coerce a Mach-O LC_UUID string to the canonical wire format
1753
+ the back-end uses for `crash.uuid → build.uuid` join: 32
1754
+ lowercase hex chars, no dashes, no whitespace. Matches the iOS
1755
+ runtime SDK's `BGSCrashReport.m` shape verbatim. Returns None on
1756
+ empty/whitespace-only input so callers never write a meaningless
1757
+ uuid into the registration payload.
1758
+
1759
+ Used at every uuid-cascade exit point — `_extract_uuid_from_app`
1760
+ (Mach-O), `_extract_first_dwarf_uuid` (dSYM scan via dwarfdump),
1761
+ and the random-fallback in run_artifact_upload_flow (uuid4) —
1762
+ so all three steps produce a single canonical shape regardless
1763
+ of which fallback hit."""
1764
+ if not raw:
1765
+ return None
1766
+ candidate = str(raw).replace('-', '').strip().lower()
1767
+ return candidate or None
1768
+
1769
+
1770
+ def _extract_first_dwarf_uuid():
1771
+ """Scan the dSYM folder for the FIRST Mach-O UUID we can find
1772
+ via dwarfdump. The Android plugin sends a single per-build
1773
+ UUID; we mirror that posture by surfacing the main binary's
1774
+ first arch slice UUID (typically arm64). Returns None on any
1775
+ error or if no dSYMs are present.
1776
+
1777
+ Returns the value already normalised through
1778
+ `_normalise_build_uuid` so callers don't need to know whether
1779
+ `parseDSYM`'s underlying tool (bugsee-cli OR dwarfdump fallback)
1780
+ emitted uppercase-hyphenated, lowercase-hyphenated, or
1781
+ lowercase-no-dashes — they all converge here."""
1782
+ folder = options.dsym_folder
1783
+ if not folder or not os.path.isdir(folder):
1784
+ return None
1785
+ for root, dirs, files in os.walk(folder):
1786
+ if not root.endswith('dSYM/Contents/Resources/DWARF'):
1787
+ continue
1788
+ for f in files:
1789
+ full = os.path.join(root, f)
1790
+ if os.path.islink(full) or os.stat(full).st_size == 0:
1791
+ continue
1792
+ uuids = parseDSYM(full)
1793
+ if uuids:
1794
+ return _normalise_build_uuid(uuids[0])
1795
+ return None
1796
+
1797
+
1798
+ def _resolve_product_binary_path():
1799
+ """Resolve the path to the linked product binary for the
1800
+ vendored-framework scan. Uses Xcode env vars
1801
+ `EXECUTABLE_PATH` and `TARGET_BUILD_DIR` which together name
1802
+ the exact Mach-O the linker emitted for this build. Returns
1803
+ None when run outside Xcode."""
1804
+ build_dir = os.environ.get('TARGET_BUILD_DIR') or options.build_dir
1805
+ exe_path = os.environ.get('EXECUTABLE_PATH')
1806
+ if not build_dir or not exe_path:
1807
+ return None
1808
+ candidate = os.path.join(build_dir, exe_path)
1809
+ return candidate if os.path.isfile(candidate) else None
1810
+
1811
+
1812
+ # ──────────────────────────────────────────────────────────────────
1813
+ # Dependency-source discovery
1814
+ # ──────────────────────────────────────────────────────────────────
1815
+
1816
+ def _find_first_above(start_dir, filename, max_levels=6):
1817
+ """Walk up from `start_dir` looking for `filename`. CocoaPods
1818
+ / SPM / Carthage lockfiles all sit at the project root, which
1819
+ is somewhere ABOVE the Xcode build directory at runtime — the
1820
+ walk-up locates them without the caller having to know the
1821
+ exact relative depth.
1822
+
1823
+ `max_levels` caps the climb so a misconfigured environment
1824
+ can't send us all the way to `/`. Returns the absolute path on
1825
+ hit or None on miss.
1826
+ """
1827
+ if not start_dir:
1828
+ return None
1829
+ current = os.path.abspath(start_dir)
1830
+ for _ in range(max_levels):
1831
+ candidate = os.path.join(current, filename)
1832
+ if os.path.isfile(candidate):
1833
+ return candidate
1834
+ parent = os.path.dirname(current)
1835
+ if parent == current:
1836
+ return None
1837
+ current = parent
1838
+ return None
1839
+
1840
+
1841
+ def _collect_all_dependencies_via_cli(cli_binary, project_root,
1842
+ product_binary):
1843
+ """Shell to `bugsee-cli ios-deps collect` and parse the JSON.
1844
+
1845
+ Part of the Option-C migration that moved iOS dep parsing to
1846
+ the Rust CLI as the single canonical implementation. The
1847
+ in-process Python parsers below are preserved as a cold-start
1848
+ fallback when the CLI isn't available.
1849
+
1850
+ Returns `(entries, scope_label, truncated)` on success or
1851
+ `None` on any failure (subprocess error, malformed JSON,
1852
+ non-zero exit). The caller falls back to the Python
1853
+ implementation on None.
1854
+ """
1855
+ if not cli_binary or not project_root:
1856
+ return None
1857
+ cmd = [cli_binary, "ios-deps", "collect",
1858
+ "--project-root", project_root]
1859
+ if product_binary:
1860
+ cmd.extend(["--product-binary", product_binary])
1861
+ try:
1862
+ result = subprocess.run(
1863
+ cmd, capture_output=True, text=True,
1864
+ timeout=30, check=False,
1865
+ )
1866
+ if result.returncode != 0:
1867
+ return None
1868
+ out = (result.stdout or "").strip()
1869
+ if not out:
1870
+ return None
1871
+ data = json.loads(out)
1872
+ # Shape validation (parity with the SDK side and the dsym
1873
+ # helper). A future CLI shape change — accidental `"entry"`
1874
+ # rename, list-instead-of-dict, non-string ids — must
1875
+ # surface as "fall back to Python parsers" rather than
1876
+ # crashing in `_build_dependencies_payload` further down
1877
+ # the pipeline.
1878
+ if not isinstance(data, dict):
1879
+ return None
1880
+ raw_entries = data.get("entries")
1881
+ if not isinstance(raw_entries, list):
1882
+ return None
1883
+ entries = [e for e in raw_entries if isinstance(e, dict)
1884
+ and isinstance(e.get("id"), str)]
1885
+ return (
1886
+ entries,
1887
+ data.get("scope_label") or "all",
1888
+ bool(data.get("truncated")),
1889
+ )
1890
+ except _CLI_CATCHALL_EXCEPTIONS:
1891
+ return None
1892
+
1893
+
1894
+ def _collect_all_dependencies(project_root):
1895
+ """Run every parser against the project root and return the
1896
+ merged entry list plus the `(scope_label, truncated)` tuple
1897
+ the payload builder needs.
1898
+
1899
+ `project_root` is the directory we walk up from to find each
1900
+ source lockfile. In an Xcode build that's `SRCROOT` (or
1901
+ `$PROJECT_DIR`); outside Xcode (CLI testing) the caller passes
1902
+ the project root explicitly.
1903
+
1904
+ Implementation: prefers `bugsee-cli ios-deps collect` (the
1905
+ single cross-language source of truth, ported from these
1906
+ Python parsers — see `bugsee-cli/src/cli/ios_deps.rs`). Falls
1907
+ back to the in-process Python parsers below when the CLI
1908
+ isn't available. Same migration pattern the VCS resolver
1909
+ landed first.
1910
+ """
1911
+ # Resolve binary path up-front — needed for both CLI invocation
1912
+ # (`--product-binary <path>`) and the Python fallback.
1913
+ binary = _resolve_product_binary_path()
1914
+
1915
+ cli_path_opt = None
1916
+ cli_version_opt = None
1917
+ try:
1918
+ cli_path_opt = getattr(options, 'cli_path', None)
1919
+ cli_version_opt = getattr(options, 'cli_version', None)
1920
+ except NameError:
1921
+ pass
1922
+ cli = resolveCli(cli_path_opt, cli_version_opt)
1923
+ if cli:
1924
+ via_cli = _collect_all_dependencies_via_cli(cli, project_root, binary)
1925
+ if via_cli is not None:
1926
+ return via_cli
1927
+
1928
+ podfile = _find_first_above(project_root, 'Podfile.lock')
1929
+ package_resolved = (
1930
+ _find_first_above(project_root, 'Package.resolved')
1931
+ # SPM CLI writes Package.resolved at the package root, but
1932
+ # Xcode-managed projects keep one under
1933
+ # `*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/`.
1934
+ # Search for that variant too if the top-level miss.
1935
+ or _find_first_above(project_root,
1936
+ os.path.join('xcshareddata', 'swiftpm',
1937
+ 'Package.resolved'))
1938
+ )
1939
+ cartfile = _find_first_above(project_root, 'Cartfile.resolved')
1940
+
1941
+ pods_entries = _parse_podfile_lock(podfile) if podfile else []
1942
+ spm_entries = _parse_package_resolved(package_resolved) if package_resolved else []
1943
+ cart_entries = _parse_cartfile_resolved(cartfile) if cartfile else []
1944
+ vendored_entries = _parse_vendored_frameworks(binary) if binary else []
1945
+
1946
+ merged, truncated = _merge_dep_entries(
1947
+ pods_entries, spm_entries, cart_entries, vendored_entries,
1948
+ )
1949
+ # `scope` here describes the COLLECTION scope (Android plugin
1950
+ # uses 'runtime' / 'runtime_direct_only'). iOS doesn't have
1951
+ # that distinction; surface 'all' so the worker's
1952
+ # compatibility check still has a stable token to compare
1953
+ # against across builds.
1954
+ return merged, "all", truncated
1955
+
1956
+
1957
+ # ──────────────────────────────────────────────────────────────────
1958
+ # Bugsee API client (build registration + presigned upload)
1959
+ # ──────────────────────────────────────────────────────────────────
1960
+
1961
+ def _request_build_registration(app_token, endpoint, body_json):
1962
+ """POST the build registration body to /v2/apps/{token}/builds
1963
+ and return the unwrapped result payload (the inner `result`
1964
+ object the appserver wraps every v2 response in). Returns None
1965
+ on any error — callers fall back to skipping the deps upload
1966
+ step.
1967
+
1968
+ Response shape from the appserver:
1969
+ success: {"ok": true, "result": { <payload> }}
1970
+ failure: {"ok": false, "error": { "type": ..., "message": ... }}
1971
+
1972
+ The Android plugin's `ApiEndpoint.unwrapResult` performs the
1973
+ same unwrap; we mirror its posture so the caller sees a flat
1974
+ payload regardless of platform.
1975
+ """
1976
+ url = endpoint.rstrip('/') + '/v2/apps/' + urllib.parse.quote(app_token) + '/builds'
1977
+ try:
1978
+ req = urllib.request.Request(
1979
+ url, data=body_json.encode('utf-8'),
1980
+ headers={'Content-Type': 'application/json'},
1981
+ method='POST',
1982
+ )
1983
+ with urllib.request.urlopen(req, timeout=30) as resp:
1984
+ raw = json.loads(resp.read().decode('utf-8'))
1985
+ except (urllib.error.URLError, urllib.error.HTTPError,
1986
+ ValueError, OSError) as e:
1987
+ print("Bugsee: build registration failed: %s" % e)
1988
+ return None
1989
+
1990
+ # Unwrap `{ok, result}` envelope. `ok: false` is surfaced as a
1991
+ # logged error and None return so the caller can short-circuit.
1992
+ if not isinstance(raw, dict):
1993
+ print("Bugsee: build registration: unexpected response type %s"
1994
+ % type(raw).__name__)
1995
+ return None
1996
+ if raw.get('ok') is False:
1997
+ err = raw.get('error') or {}
1998
+ if isinstance(err, dict):
1999
+ print("Bugsee: build registration rejected: %s (%s)"
2000
+ % (err.get('message', '(no message)'), err.get('type', '?')))
2001
+ else:
2002
+ print("Bugsee: build registration rejected (no error detail).")
2003
+ return None
2004
+ result = raw.get('result')
2005
+ if isinstance(result, dict):
2006
+ return result
2007
+ # Some appserver routes return a flat payload without the
2008
+ # `{ok, result}` envelope (legacy endpoints). Return the raw
2009
+ # dict in that case so we don't lose information.
2010
+ return raw
2011
+
2012
+
2013
+ def _put_dependencies_blob(endpoint_url, gz_bytes):
2014
+ """PUT the gzipped deps JSON to the presigned URL the appserver
2015
+ returned. Matches the Android plugin's signing posture:
2016
+ `Content-Type: application/octet-stream`, no Content-Encoding
2017
+ header on the producer PUT (the worker sets it when moving
2018
+ bytes to the store bucket). Returns True on success.
2019
+ """
2020
+ try:
2021
+ req = urllib.request.Request(
2022
+ endpoint_url, data=gz_bytes,
2023
+ headers={
2024
+ 'Content-Type': 'application/octet-stream',
2025
+ # Precise Content-Length so urllib doesn't fall back
2026
+ # to chunked transfer-encoding (which S3 presigned
2027
+ # PUT signatures don't accept — the worker would see
2028
+ # the body but the signature check 403s). The sibling
2029
+ # `_put_timings_blob` and `_put_artifact_blob` both
2030
+ # set this explicitly; this helper used to rely on
2031
+ # urllib's auto-derive which works on most paths but
2032
+ # was inconsistent with the documented S3 signing
2033
+ # posture.
2034
+ 'Content-Length': str(len(gz_bytes)),
2035
+ },
2036
+ method='PUT',
2037
+ )
2038
+ with urllib.request.urlopen(req, timeout=120) as resp:
2039
+ status = getattr(resp, 'status', 200)
2040
+ return 200 <= status < 300
2041
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
2042
+ print("Bugsee: dependencies upload failed: %s" % e)
2043
+ return False
2044
+
2045
+
2046
+ def _put_timings_blob(url, gz_bytes, debug=False):
2047
+ """Best-effort PUT of the gzipped per-task timeline DETAIL blob to
2048
+ the presigned `timings_upload_endpoint` the metadata POST returned.
2049
+
2050
+ Mirrors `_put_dependencies_blob` exactly — independent of the
2051
+ artefact upload, runs on the build-info-only path too, never
2052
+ raises (a failed PUT degrades to "no Gantt chart for this build"
2053
+ and must not break the user's archive flow). Returns True on a
2054
+ 2xx, False otherwise.
2055
+
2056
+ `Content-Type` is hard-set to `application/octet-stream` because
2057
+ the back-end signs the presigned URL over exactly that value —
2058
+ S3 403s the PUT on any Content-Type mismatch.
2059
+ """
2060
+ if not url or not gz_bytes:
2061
+ return False
2062
+ try:
2063
+ put_req = urllib.request.Request(url, data=gz_bytes, method='PUT')
2064
+ # Precise Content-Length so the signed request body-length
2065
+ # matches; chunked transfer-encoding breaks S3 presigned PUTs.
2066
+ put_req.add_header('Content-Length', str(len(gz_bytes)))
2067
+ put_req.add_header('Content-Type', 'application/octet-stream')
2068
+ with urllib.request.urlopen(put_req, timeout=120) as resp:
2069
+ status = resp.status
2070
+ except urllib.error.HTTPError as e:
2071
+ print("Bugsee: timings PUT failed (HTTP %d)" % e.code)
2072
+ return False
2073
+ except Exception as e:
2074
+ print("Bugsee: timings PUT network error: %s" % e)
2075
+ return False
2076
+
2077
+ if 200 <= status < 300:
2078
+ if debug:
2079
+ print("Bugsee: timings blob uploaded (%d bytes, HTTP %d)"
2080
+ % (len(gz_bytes), status))
2081
+ return True
2082
+ print("Bugsee: timings PUT unexpected status: %d" % status)
2083
+ return False
2084
+
2085
+
2086
+ def _put_artifact_blob(url, ipa_path, debug=False):
2087
+ """Best-effort PUT of the .ipa bytes to the presigned
2088
+ `endpoint` the metadata POST returned.
2089
+
2090
+ Streams from disk instead of slurping into memory — modern iOS
2091
+ apps can exceed 300 MB once extensions + assets are included,
2092
+ and macOS CI runners have modest memory budgets.
2093
+
2094
+ `Content-Type` is hard-set to `application/octet-stream`
2095
+ (matches the SDK BugseeAgent's upload_build posture; the
2096
+ back-end signs the presigned URL over exactly that content
2097
+ type, so S3 403s on any mismatch). `Content-Length` is
2098
+ pre-computed because urllib's default chunked transfer-encoding
2099
+ breaks S3 presigned PUT signatures.
2100
+ """
2101
+ if not url or not ipa_path:
2102
+ return False
2103
+ try:
2104
+ ipa_size = os.path.getsize(ipa_path)
2105
+ except OSError as e:
2106
+ print("Bugsee: could not stat artefact for upload: %s" % e)
2107
+ return False
2108
+ try:
2109
+ with open(ipa_path, 'rb') as fp:
2110
+ put_req = urllib.request.Request(url, data=fp, method='PUT')
2111
+ put_req.add_header('Content-Length', str(ipa_size))
2112
+ put_req.add_header('Content-Type', 'application/octet-stream')
2113
+ with urllib.request.urlopen(put_req, timeout=600) as resp:
2114
+ status = resp.status
2115
+ except urllib.error.HTTPError as e:
2116
+ print("Bugsee: artefact PUT failed (HTTP %d)" % e.code)
2117
+ return False
2118
+ except Exception as e:
2119
+ print("Bugsee: artefact PUT network error: %s" % e)
2120
+ return False
2121
+
2122
+ if 200 <= status < 300:
2123
+ if debug:
2124
+ print("Bugsee: artefact uploaded (%d bytes, HTTP %d)"
2125
+ % (ipa_size, status))
2126
+ return True
2127
+ print("Bugsee: artefact PUT unexpected status: %d" % status)
2128
+ return False
2129
+
2130
+
2131
+ def run_artifact_upload_flow(app_token, endpoint, app_path,
2132
+ version=None, build_number=None,
2133
+ request_artifact_upload=True,
2134
+ debug=False):
2135
+ """Package an `.app` into a synthetic `.ipa` and upload it to
2136
+ Bugsee for size analysis. Mirrors the SDK BugseeAgent's
2137
+ `run_size_analysis_flow` minus the chunked-upload opt-in and
2138
+ the in-build size-check evaluation (those can land in a
2139
+ follow-up).
2140
+
2141
+ Returns True on overall success (build registered AND, when
2142
+ requested, artefact bytes shipped); False on any failure.
2143
+
2144
+ `request_artifact_upload=False` flips this into a build-info-
2145
+ only registration that records the artefact_size on the
2146
+ server WITHOUT shipping the bytes — useful for firewalled CI
2147
+ or privacy-sensitive setups where the dashboard's size-trend
2148
+ chart is enough.
2149
+ """
2150
+ if not app_path or not os.path.isdir(app_path):
2151
+ print("Bugsee: --app-path is required and must be a directory; got %r"
2152
+ % app_path)
2153
+ return False
2154
+ if not app_path.endswith('.app'):
2155
+ print("Bugsee: --app-path must end in .app; got %r" % app_path)
2156
+ return False
2157
+
2158
+ temp_dir = tempfile.mkdtemp(prefix='bugsee-artifact-')
2159
+ try:
2160
+ # Resolve Info.plist provenance BEFORE packaging — we want
2161
+ # `package_id` + `version` + `build` on the registration
2162
+ # payload so the back-end's /builds/baseline lookup keys on
2163
+ # the same triple the SDK side uses. Otherwise every fastlane-
2164
+ # registered build lives in its own bucket and the size-trend
2165
+ # chart never finds a delta.
2166
+ bundle_id, plist_version, plist_build = resolve_bundle_info_from_app(app_path)
2167
+ # Caller-supplied args override the plist (a CI step may want
2168
+ # to label a build differently from what Xcode wrote in).
2169
+ effective_version = version or plist_version
2170
+ effective_build = build_number or plist_build
2171
+
2172
+ # Sanitise the .ipa filename stem against path-traversal — the
2173
+ # bundle id / app name flow from Info.plist values we read but
2174
+ # don't fully validate.
2175
+ stem = bundle_id or os.path.basename(app_path)[:-4] or 'build'
2176
+ safe_stem = re.sub(r'[^A-Za-z0-9._-]', '_', os.path.basename(stem))
2177
+ ipa_path = os.path.join(temp_dir, '%s.ipa' % (safe_stem or 'build'))
2178
+ print("Bugsee: packaging %s → %s"
2179
+ % (os.path.basename(app_path), ipa_path))
2180
+ package_app_as_ipa(app_path, ipa_path)
2181
+
2182
+ try:
2183
+ artifact_size = os.path.getsize(ipa_path)
2184
+ except OSError:
2185
+ artifact_size = 0
2186
+
2187
+ # Build the registration payload. Mirrors the SDK's
2188
+ # `run_size_analysis_flow` payload shape so the back-end's
2189
+ # `_register_build` route treats both producers identically.
2190
+ payload = {
2191
+ 'format': 'ipa',
2192
+ 'request_artifact_upload': bool(request_artifact_upload),
2193
+ }
2194
+ if bundle_id:
2195
+ # `package_id` is one of the back-end's baseline lookup
2196
+ # keys. Without it the dashboard size-trend chart never
2197
+ # finds a baseline for this build's family.
2198
+ payload['package_id'] = bundle_id
2199
+ if effective_version:
2200
+ payload['version'] = effective_version
2201
+ if effective_build:
2202
+ payload['build'] = effective_build
2203
+ if artifact_size > 0:
2204
+ payload['artifact_size'] = artifact_size
2205
+ # $CONFIGURATION is set by Xcode in build phases and by
2206
+ # `xcodebuild` invocations; the second baseline-lookup key
2207
+ # the back-end consumes. Optional — when absent, the back-end
2208
+ # treats it as the implicit "Release" bucket.
2209
+ config = (os.environ.get('CONFIGURATION') or '').strip()
2210
+ if config:
2211
+ payload['build_configuration'] = config
2212
+
2213
+ # `uuid` is the build identifier the back-end uses to join
2214
+ # crashes to this build (`crash.uuid → build`). Resolution
2215
+ # cascade matches the SDK's posture:
2216
+ # 1. Main executable's Mach-O LC_UUID (deterministic per
2217
+ # linker invocation; the same value the runtime reports).
2218
+ # 2. First dwarf UUID from the legacy dSYM scan (when the
2219
+ # fastlane lane runs the dSYM upload action in the same
2220
+ # invocation, options.dsym_folder is set).
2221
+ # 3. uuid.uuid4() — pure random fallback. The build record
2222
+ # is still useful for the size-trend chart but crash-
2223
+ # to-build join is best-effort in this case.
2224
+ # All cascade exits funnel through `_normalise_build_uuid`
2225
+ # so the registration payload's `uuid` is always the canonical
2226
+ # 32-char lowercase-no-dash shape the back-end joins on.
2227
+ # `_extract_uuid_from_app` and `_extract_first_dwarf_uuid`
2228
+ # both already normalise on their way out (#2/#4/#11 fix);
2229
+ # the random fallback also goes through the helper for the
2230
+ # same reason.
2231
+ build_uuid = _extract_uuid_from_app(app_path)
2232
+ if not build_uuid:
2233
+ try:
2234
+ build_uuid = _extract_first_dwarf_uuid()
2235
+ except NameError:
2236
+ build_uuid = None
2237
+ if not build_uuid:
2238
+ build_uuid = _normalise_build_uuid(str(uuid.uuid4()))
2239
+ payload['uuid'] = build_uuid
2240
+
2241
+ # VCS metadata (best-effort). Resolves via bugsee-cli when on
2242
+ # PATH, falls back to in-process Python otherwise. Guard
2243
+ # against the same `options`-NameError window as the dSYM
2244
+ # UUID extraction above (resolve_vcs_metadata's CLI path may
2245
+ # consult `options.cli_path` / `options.cli_version`).
2246
+ try:
2247
+ vcs = resolve_vcs_metadata(os.getcwd())
2248
+ except NameError:
2249
+ vcs = None
2250
+ if vcs:
2251
+ payload['vcs'] = vcs
2252
+
2253
+ if debug:
2254
+ print("Bugsee: artefact registration payload = %s"
2255
+ % json.dumps(payload))
2256
+
2257
+ result = _request_build_registration(
2258
+ app_token, endpoint, json.dumps(payload))
2259
+ if result is None:
2260
+ return False
2261
+
2262
+ if not request_artifact_upload:
2263
+ # Build-info-only path: nothing more to ship. The server
2264
+ # recorded artifact_size for the size-trend chart.
2265
+ if debug:
2266
+ print("Bugsee: build-info-only registration complete")
2267
+ return True
2268
+
2269
+ presigned = result.get('endpoint') if isinstance(result, dict) else None
2270
+ if not presigned:
2271
+ print("Bugsee: build registration returned no presigned endpoint")
2272
+ return False
2273
+
2274
+ return _put_artifact_blob(presigned, ipa_path, debug=debug)
2275
+ finally:
2276
+ shutil.rmtree(temp_dir, ignore_errors=True)
2277
+
2278
+
2279
+ def _upload_build_info_bundle(upload_url, deps_gz, timings_gz):
2280
+ """Ship the build-info components as ONE zstd bundle via
2281
+ `bugsee-cli upload build-info --upload-url <url>` (pre-signed mode:
2282
+ the plugin already registered the build, so the CLI just PUTs).
2283
+
2284
+ `deps_gz` / `timings_gz` are the gzipped per-blob payloads; they're
2285
+ gunzipped here because the bundle's `dependencies.json` /
2286
+ `timings.json` entries carry RAW JSON (the CLI does the zstd packing
2287
+ and the worker re-gzips on store, so the stored bytes match the legacy
2288
+ per-blob path). At least one must be non-None.
2289
+
2290
+ Returns True on success. Returns False on ANY failure — including no
2291
+ resolvable CLI — so the caller falls back to the legacy per-blob gzip
2292
+ PUTs, which target independent presigned URLs (an independent upload
2293
+ mechanism, so retrying them is worthwhile even on a substantive
2294
+ bundle failure).
2295
+ """
2296
+ cli = resolveCli(getattr(options, 'cli_path', None),
2297
+ getattr(options, 'cli_version', None))
2298
+ if not cli:
2299
+ return False
2300
+ tmpdir = tempfile.mkdtemp(prefix='bugsee-build-info-')
2301
+ try:
2302
+ argv = [cli, "upload", "build-info", "--upload-url", upload_url]
2303
+ if deps_gz is not None:
2304
+ deps_path = os.path.join(tmpdir, "dependencies.json")
2305
+ with open(deps_path, 'wb') as fp:
2306
+ fp.write(gzip.decompress(deps_gz))
2307
+ argv += ["--deps", deps_path]
2308
+ if timings_gz is not None:
2309
+ timings_path = os.path.join(tmpdir, "timings.json")
2310
+ with open(timings_path, 'wb') as fp:
2311
+ fp.write(gzip.decompress(timings_gz))
2312
+ argv += ["--timings", timings_path]
2313
+ result = subprocess.run(
2314
+ argv, capture_output=True, text=True, timeout=120, check=False)
2315
+ stderr = (result.stderr or '').strip()
2316
+ if stderr:
2317
+ print("Bugsee: bugsee-cli output:\n%s" % stderr)
2318
+ return result.returncode == 0
2319
+ except _CLI_CATCHALL_EXCEPTIONS as e:
2320
+ print("Bugsee: build-info bundle CLI invocation failed: %s" % e)
2321
+ return False
2322
+ finally:
2323
+ shutil.rmtree(tmpdir, ignore_errors=True)
2324
+
2325
+
2326
+ def _run_dependencies_pipeline(app_token, project_root):
2327
+ """Top-level driver wired into [main] when --deps is enabled.
2328
+
2329
+ Collects + uploads the project's dependency graph AND the
2330
+ build's xcactivitylog-derived timings (when available) in ONE
2331
+ build-registration round-trip. Both pieces of data are
2332
+ optional — if neither produced useful output the function
2333
+ short-circuits before the network call. Otherwise the
2334
+ registration POST carries:
2335
+
2336
+ - `build_metadata.timings` — inline summary (total_ms,
2337
+ category sums, top tasks); always present when the
2338
+ xcactivitylog was found.
2339
+ - `dependencies_summary` — total/direct/transitive counts,
2340
+ collection_config; present when at least one lockfile
2341
+ parsed.
2342
+
2343
+ The server responds with up to two presigned PUT endpoints:
2344
+
2345
+ - `dependencies_upload_endpoint` — for the full deps blob.
2346
+ - `timings_upload_endpoint` — for the full per-task
2347
+ Gantt-detail blob.
2348
+
2349
+ Each PUT is best-effort and independent: a failed deps PUT
2350
+ does NOT skip the timings PUT, and vice versa. The function
2351
+ returns True only when registration succeeded AND every
2352
+ attempted PUT succeeded; partial failures return False but
2353
+ don't raise.
2354
+
2355
+ Failures here MUST NOT block the existing symbols-upload
2356
+ step — both pipelines are independent.
2357
+ """
2358
+ # Per-feature gating. Each side of the cross-producer
2359
+ # handshake decides independently whether THIS agent should
2360
+ # contribute deps / timings — see the --no-deps / --no-timings
2361
+ # OptionParser entries. The fastlane Ruby action sets these
2362
+ # flags based on the manifest the iOS SDK's BugseeAgent build
2363
+ # phase emitted (or didn't) for this build.
2364
+ collect_deps = getattr(options, 'collect_deps', True)
2365
+ collect_timings = getattr(options, 'collect_timings', True)
2366
+
2367
+ if collect_deps:
2368
+ entries, scope_label, truncated = _collect_all_dependencies(project_root)
2369
+ else:
2370
+ entries, scope_label, truncated = [], 'all', False
2371
+
2372
+ if collect_timings:
2373
+ timings_summary, timings_gz = resolve_build_timings(os.environ)
2374
+ else:
2375
+ timings_summary, timings_gz = None, None
2376
+
2377
+ have_deps = bool(entries)
2378
+ have_timings = timings_summary is not None
2379
+
2380
+ if not have_deps and not have_timings:
2381
+ print("Bugsee: no dependency sources found and no build timings "
2382
+ "available; skipping build-info upload.")
2383
+ return False
2384
+
2385
+ body = _collect_build_metadata()
2386
+ # Guard against writing `"uuid": null` into the POST body when the
2387
+ # dSYM scan yields nothing (e.g. a lane that ships build-info-only
2388
+ # with no dSYMs alongside). The historic assignment unconditionally
2389
+ # set the value, leaking the None into the JSON.
2390
+ #
2391
+ # `_extract_first_dwarf_uuid` now normalises through
2392
+ # `_normalise_build_uuid` on its way out (lifted from the per-
2393
+ # callsite normalisation that used to live only in
2394
+ # run_artifact_upload_flow), so `dwarf_uuid` here is already the
2395
+ # canonical 32-char lowercase-no-dash shape the back-end joins on.
2396
+ dwarf_uuid = _extract_first_dwarf_uuid()
2397
+ if dwarf_uuid:
2398
+ body["uuid"] = dwarf_uuid
2399
+ body["request_artifact_upload"] = False
2400
+ body["request_dependencies_upload"] = have_deps
2401
+ body["request_timings_upload"] = timings_gz is not None
2402
+ # Opt into the converged build-info bundle (Phase D). The server only
2403
+ # signs a build_info_upload_endpoint when the org's
2404
+ # BUGSEE_FEATURE_BUILD_INFO_BUNDLE_ENABLED flag is on; the deps/timings
2405
+ # flags above stay set so the legacy path remains available for
2406
+ # fallback (and for non-flagged orgs) during the soak.
2407
+ body["request_build_info_upload"] = have_deps or have_timings
2408
+
2409
+ deps_blob_gz = None
2410
+ if have_deps:
2411
+ deps_summary, deps_blob = _build_dependencies_payload(
2412
+ entries, truncated, scope_label,
2413
+ include_selected_reason=False,
2414
+ max_count=DEPENDENCIES_MAX_COUNT,
2415
+ )
2416
+ body["dependencies_summary"] = deps_summary
2417
+ deps_blob_gz = _gzip_json_bytes(deps_blob)
2418
+
2419
+ if have_timings:
2420
+ # Inline timings summary lives under build_metadata so the
2421
+ # server-side rollup (total_ms, top tasks, category sums)
2422
+ # is available without fetching the detail blob. The blob
2423
+ # itself is uploaded separately via the presigned URL.
2424
+ body.setdefault("build_metadata", {})["timings"] = timings_summary
2425
+
2426
+ response = _request_build_registration(
2427
+ app_token, options.endpoint, json.dumps(body))
2428
+ if not response:
2429
+ return False
2430
+
2431
+ # Build-info bundle (Phase D): when the server signed a
2432
+ # build_info_upload_endpoint (the org is flagged on), the escape hatch
2433
+ # is off, and a CLI resolves, ship deps+timings as ONE zstd bundle via
2434
+ # `bugsee-cli upload build-info` instead of the two legacy gzip PUTs.
2435
+ # Fails closed — any miss/failure falls through to the legacy PUTs.
2436
+ build_info_endpoint = response.get('build_info_upload_endpoint')
2437
+ if (build_info_endpoint
2438
+ and not _env_truthy(os.environ.get('BUGSEE_LEGACY_BUILDINFO_GZIP'))
2439
+ and (have_deps or have_timings)):
2440
+ if _upload_build_info_bundle(
2441
+ build_info_endpoint, deps_blob_gz, timings_gz):
2442
+ print("Bugsee: build-info bundle uploaded via bugsee-cli.")
2443
+ return True
2444
+ print("Bugsee: build-info bundle unavailable/failed; falling back "
2445
+ "to legacy per-blob upload.")
2446
+
2447
+ overall_ok = True
2448
+
2449
+ if have_deps and deps_blob_gz is not None:
2450
+ deps_endpoint = response.get('dependencies_upload_endpoint')
2451
+ if not deps_endpoint:
2452
+ print("Bugsee: server did not return a dependencies upload "
2453
+ "endpoint — skipping deps PUT.")
2454
+ overall_ok = False
2455
+ else:
2456
+ print("Bugsee: uploading dependencies blob (%d entries, "
2457
+ "%d bytes gzipped)"
2458
+ % (len(entries), len(deps_blob_gz)))
2459
+ if not _put_dependencies_blob(deps_endpoint, deps_blob_gz):
2460
+ overall_ok = False
2461
+
2462
+ if timings_gz is not None:
2463
+ timings_endpoint = response.get('timings_upload_endpoint')
2464
+ if not timings_endpoint:
2465
+ # Older appservers may not surface the timings endpoint
2466
+ # yet — the inline summary still landed via the POST,
2467
+ # so this isn't a hard failure.
2468
+ print("Bugsee: server did not return a timings upload "
2469
+ "endpoint — inline summary kept; per-task detail "
2470
+ "blob skipped.")
2471
+ else:
2472
+ print("Bugsee: uploading build timings blob (%d bytes gzipped)"
2473
+ % len(timings_gz))
2474
+ if not _put_timings_blob(timings_endpoint, timings_gz):
2475
+ overall_ok = False
2476
+
2477
+ return overall_ok
2478
+
2479
+
33
2480
  def isInUploadedList(images, imageList):
34
2481
  for image in images:
35
2482
  if (image in imageList):
@@ -49,7 +2496,55 @@ def loadUploadedList():
49
2496
  except Exception as error:
50
2497
  return []
51
2498
 
2499
+ def _parse_dsym_via_cli(fullPath):
2500
+ """Shell to `bugsee-cli dsym uuid <path>` for the UUID list.
2501
+ Returns a list of UUID strings on success, or None on any
2502
+ failure. Part of the Option-C migration that moved dSYM UUID
2503
+ extraction to the Rust CLI (which keys off
2504
+ `symbolic-debuginfo`'s Mach-O parser — no `/usr/bin/dwarfdump`
2505
+ dependency)."""
2506
+ if not fullPath:
2507
+ return None
2508
+ cli_path_opt = None
2509
+ cli_version_opt = None
2510
+ try:
2511
+ cli_path_opt = getattr(options, 'cli_path', None)
2512
+ cli_version_opt = getattr(options, 'cli_version', None)
2513
+ except NameError:
2514
+ pass
2515
+ cli = resolveCli(cli_path_opt, cli_version_opt)
2516
+ if not cli:
2517
+ return None
2518
+ try:
2519
+ result = subprocess.run(
2520
+ [cli, "dsym", "uuid", fullPath],
2521
+ capture_output=True, text=True, timeout=30, check=False,
2522
+ )
2523
+ if result.returncode != 0:
2524
+ return None
2525
+ out = (result.stdout or "").strip()
2526
+ if not out:
2527
+ return None
2528
+ data = json.loads(out)
2529
+ if not isinstance(data, list):
2530
+ return None
2531
+ # Each entry must be a UUID string.
2532
+ return [str(u) for u in data if isinstance(u, str)]
2533
+ except _CLI_CATCHALL_EXCEPTIONS:
2534
+ return None
2535
+
2536
+
52
2537
  def parseDSYM(fullPath):
2538
+ """Extract Mach-O UUIDs from a dSYM bundle or Mach-O binary.
2539
+
2540
+ Prefers `bugsee-cli dsym uuid` (canonical Rust implementation;
2541
+ no /usr/bin/dwarfdump dependency). Falls back to the in-
2542
+ process dwarfdump shell-out below when the CLI isn't
2543
+ available."""
2544
+ via_cli = _parse_dsym_via_cli(fullPath)
2545
+ if via_cli is not None:
2546
+ return via_cli
2547
+
53
2548
  images = []
54
2549
  try:
55
2550
  out = subprocess.run(['/usr/bin/dwarfdump', '-u', fullPath], check=True, capture_output=True, text=True).stdout
@@ -73,43 +2568,29 @@ def deobfuscateDSYM(fullPath, mapsPath):
73
2568
  return
74
2569
  return
75
2570
 
76
- def getIcon():
77
- if not options.from_xcode:
78
- # No icon extraction when run outside of XCode
79
- # TODO: Get it from fastlane if we run after build?
80
- return None
81
- try:
82
- info_file_path = os.path.join(options.build_dir, os.environ['INFOPLIST_PATH'])
83
- info_file_dir = os.path.dirname(info_file_path)
84
- # p = subprocess.Popen('/usr/libexec/PlistBuddy -c "Print :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles" %s' % info_file_path,
85
- # stdout=subprocess.PIPE, shell=True)
86
-
87
- # stdout, stderr = p.communicate()
88
- # icons = stdout.split()
89
- # if len(icons) > 4:
90
- # return icons[2:-1]
91
- icons = [
92
- '114x114',
93
- '120x120', 'AppIcon60x60@2x', 'AppIcon40x40@3x',
94
- '144x144',
95
- '180x180', 'AppIcon60x60@3x',
96
- '87x87', 'AppIcon29x29@3x',
97
- '80x80', 'AppIcon40x40@2x',
98
- '72x72',
99
- '58x58', 'AppIcon29x29@2x',
100
- '57x57',
101
- '29x29'
102
- ]
103
-
104
- for icon in icons:
105
- path = os.path.join(info_file_dir, icon + '.png')
106
- if os.path.isfile(path):
107
- return path
108
-
109
- except Exception as error:
110
- return None
2571
+ # Note on app icon attachment (removed in 1.1.0):
2572
+ #
2573
+ # The legacy upload pipeline shipped the Xcode-uncrushed launcher
2574
+ # PNG inside the dSYM zip; the worker's symbols job extracted
2575
+ # `icon.png` from the bundle root and called `icons.store_icon(...,
2576
+ # 'build', ...)` (see worker/jobs/symbols.py). Two helpers — getIcon
2577
+ # and uncrushIcon (running `xcrun pngcrush -revert-iphone-
2578
+ # optimizations`) — produced that PNG.
2579
+ #
2580
+ # bugsee-cli's --type dsym path takes no --icon (the CLI rejects it
2581
+ # at parse-time; see bugsee-cli/src/cli/debug_files.rs), so the
2582
+ # build-extraction code path is dead. The dashboard falls back to
2583
+ # the worker's appstore fetch (icon.source='appstore') for any app
2584
+ # that's published on the App Store, but enterprise / TestFlight /
2585
+ # internal builds now render without an icon until icon attachment
2586
+ # is re-wired into the build registration body
2587
+ # (`_request_build_registration` would be the place).
2588
+ #
2589
+ # The legacy helpers are removed here rather than left as comments
2590
+ # because git history preserves them — searching for getIcon /
2591
+ # uncrushIcon in the log of this file surfaces the exact 1.0.4
2592
+ # implementation if someone needs to re-introduce it.
111
2593
 
112
- return None
113
2594
 
114
2595
  def getVersionAndBuild(zipFile):
115
2596
  version = None
@@ -122,8 +2603,20 @@ def getVersionAndBuild(zipFile):
122
2603
  else:
123
2604
  try:
124
2605
  info_file_path = os.path.join(options.build_dir, os.environ['INFOPLIST_PATH'])
125
- cmd = '/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" -c "Print :CFBundleVersion" "' + info_file_path + '"'
126
- p = subprocess.Popen([cmd], stdout=subprocess.PIPE, shell=True)
2606
+ # Argv list — never shell=True. The PlistBuddy invocation
2607
+ # used to be `subprocess.Popen([cmd], shell=True)` with the
2608
+ # info_file_path concatenated into the command string,
2609
+ # which broke quoting on any project path containing a
2610
+ # quote/apostrophe AND opened a shell-injection sink if
2611
+ # INFOPLIST_PATH or build_dir came from an attacker-
2612
+ # controlled env var. The list form bypasses the shell
2613
+ # entirely; quoting is no longer the script's problem.
2614
+ p = subprocess.Popen([
2615
+ '/usr/libexec/PlistBuddy',
2616
+ '-c', 'Print :CFBundleShortVersionString',
2617
+ '-c', 'Print :CFBundleVersion',
2618
+ info_file_path,
2619
+ ], stdout=subprocess.PIPE)
127
2620
  stdout, stderr = p.communicate()
128
2621
  version, build = stdout.decode().split()
129
2622
  except Exception as error:
@@ -131,19 +2624,6 @@ def getVersionAndBuild(zipFile):
131
2624
 
132
2625
  return (version, build)
133
2626
 
134
- def uncrushIcon(icon, tempDir):
135
- try:
136
- dest = os.path.join(tempDir, 'icon.png')
137
- print("Uncrushing Icon PNG file to %s" % dest)
138
- cmd = '/usr/bin/xcrun pngcrush -revert-iphone-optimizations "'+ icon + '" "' + dest + '"'
139
- p = subprocess.Popen([cmd], stdout=subprocess.PIPE, shell=True)
140
-
141
- stdout, stderr = p.communicate()
142
- except Exception as error:
143
- return None
144
-
145
- return dest
146
-
147
2627
  def requestEndPoint(version, build):
148
2628
  encoded_data = json.dumps({
149
2629
  'version': version,
@@ -159,16 +2639,21 @@ def requestEndPoint(version, build):
159
2639
  return json.loads(text.decode())
160
2640
 
161
2641
  def uploadBundle(endpoint, filePath):
162
- # TODO: Change it to urllib2 as well
163
- cmd = 'curl -v -T "' + filePath + '" "' + endpoint + '"' + ' --write-out %{http_code} --silent --output /dev/null'
164
- p = subprocess.Popen([cmd],
165
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
166
- stdout, stderr = p.communicate()
167
- code = stdout.decode()
168
- if code == '200':
169
- return True
170
-
171
- return False
2642
+ # PUT the file via urllib instead of shelling to curl. The
2643
+ # original `curl -v -T "<filePath>" "<endpoint>"` was constructed
2644
+ # by string concatenation and passed to `subprocess.Popen([cmd],
2645
+ # shell=True)` — broken on any filePath/endpoint containing
2646
+ # quotes and a shell-injection sink for the server-returned
2647
+ # endpoint URL. urllib was already imported for adjacent
2648
+ # uploadStatus/requestEndPoint calls; reuse it.
2649
+ try:
2650
+ with open(filePath, 'rb') as f:
2651
+ data = f.read()
2652
+ req = urllib.request.Request(endpoint, data=data, method='PUT')
2653
+ with urllib.request.urlopen(req) as resp:
2654
+ return 200 <= resp.status < 300
2655
+ except Exception:
2656
+ return False
172
2657
 
173
2658
  def updateStatus(symbolId):
174
2659
  encoded_data = json.dumps({
@@ -206,13 +2691,848 @@ def uploadZipFile(zipFileLocation):
206
2691
 
207
2692
  return False
208
2693
 
2694
+ # ──────────────────────────────────────────────────────────────────
2695
+ # Build timings via xcactivitylog (ported from SDK BugseeAgent)
2696
+ #
2697
+ # Parses Xcode's .xcactivitylog (SLF0-compressed binary format) to
2698
+ # extract per-task durations + category rollups for the build
2699
+ # registration's `build_metadata.timings` sub-object. Same schema
2700
+ # the Android Gradle plugin's BuildTimingService emits, so the
2701
+ # back-end renders iOS and Android timings from one wire shape.
2702
+ # ──────────────────────────────────────────────────────────────────
2703
+
2704
+ _SLF_SECTION_CLASS = 'IDEActivityLogSection'
2705
+ _SLF_COMMAND_CLASS = 'IDEActivityLogCommandInvocationSection'
2706
+ _SLF_SECTION_CLASSES = frozenset((_SLF_SECTION_CLASS, _SLF_COMMAND_CLASS))
2707
+
2708
+ # SLF token delimiters. The three LENGTH-PREFIXED forms (`"` string,
2709
+ # `%` className-def, `*` JSON/data blob) carry a DECIMAL length whose
2710
+ # raw bytes follow; the rest are SIMPLE (`#` hex int, `^` hex double,
2711
+ # `@` hex classRef, `(` array/object element count, `-` null).
2712
+ _SLF_LEN_PREFIXED = b'"%*'
2713
+ _SLF_SIMPLE = b'#^@(-'
2714
+ _SLF_DELIMS = frozenset(_SLF_LEN_PREFIXED + _SLF_SIMPLE)
2715
+ _SLF_LEN_PREFIXED_SET = frozenset(_SLF_LEN_PREFIXED)
2716
+
2717
+
2718
+ # Per-category title classifier for iOS build events. Wire shape is
2719
+ # the same as the Android Gradle plugin's (`managed_code_ms` /
2720
+ # `native_ms` / `resources_ms` / `packaging_ms` / `other_ms`) so
2721
+ # back-end + front-end render both platforms from a single schema.
2722
+ # Semantic mapping for iOS:
2723
+ #
2724
+ # managed_code_ms → never emitted on iOS. Reserved for JVM-
2725
+ # bytecode pipelines (kotlinc / javac / R8 /
2726
+ # desugar) on Android. Everything iOS compiles
2727
+ # is native code — Swift, Obj-C, C and C++ all
2728
+ # flow through clang/swiftc into the Mach-O.
2729
+ # native_ms → Swift + Obj-C + C/C++ compile units, Swift
2730
+ # module planning / emission / clang module
2731
+ # building. Dominates almost every iOS build.
2732
+ # Matches the Android semantic (JNI/C++ compile
2733
+ # via CMake / NDK lands in `native_ms` there).
2734
+ # resources_ms → asset catalogs, storyboards, xibs, strings,
2735
+ # plist processing, resource copies.
2736
+ # packaging_ms → linking, code signing, framework embedding,
2737
+ # strip, touch, dSYM generation, Swift stdlib
2738
+ # embedding (conceptually "get the runtime into
2739
+ # the bundle").
2740
+ # other_ms → build-graph dependency computation, tool
2741
+ # version discovery, auxiliary file generation,
2742
+ # Swift Package Manager resolution steps — the
2743
+ # scaffolding that isn't otherwise classified.
2744
+ # Typically <1% on a simple app build; SPM-heavy
2745
+ # projects can push this higher as `Computing
2746
+ # package information` / `Copying Package.resolved`
2747
+ # / `Resolve Package Graph` all land in this
2748
+ # bucket.
2749
+ #
2750
+ # Wrapper prefixes filter FIRST so container sections contribute to
2751
+ # no category; within the remaining rules, precedence is native →
2752
+ # resources → packaging (each list is checked in order and the
2753
+ # first match wins).
2754
+ _XCACTIVITYLOG_CATEGORY_NATIVE_PATTERNS = (
2755
+ # Per-source compile events: "Compile Foo.swift (arm64)", etc.
2756
+ re.compile(r'^Compile \S+\.(swift|m|mm|c|cpp|cxx|cc)\b', re.IGNORECASE),
2757
+ re.compile(r'^CompileSwiftSources\b'),
2758
+ re.compile(r'^CompileC\b'),
2759
+ re.compile(r'^CompileSwift\b'),
2760
+ # Swift toolchain phases — planning / driver / module emission.
2761
+ re.compile(r'^Planning Swift module\b'),
2762
+ re.compile(r'^SwiftDriver\b'),
2763
+ re.compile(r'^Emit(?:ting)? [Ss]wift [Mm]odule\b'),
2764
+ re.compile(r'^Emitting module for\b'),
2765
+ re.compile(r'^SwiftMergeGeneratedHeaders\b'),
2766
+ re.compile(r'^SwiftVerifyEmittedModuleInterface\b'),
2767
+ re.compile(r'^Generate Swift Constant Values\b'),
2768
+ # Explicit module / Clang module builds pulled into Swift.
2769
+ # NOTE: `^Compiling Clang module` is also special-cased in
2770
+ # `_classify_section_title` ahead of the wrapper check — the
2771
+ # `Compiling ` wrapper prefix would otherwise intercept it.
2772
+ re.compile(r'^Compiling Clang module\b'),
2773
+ re.compile(r'^Precompile module\b'),
2774
+ re.compile(r'^Explicitly Built\b'),
2775
+ re.compile(r'^Discovering version info for swiftc\b'),
2776
+ re.compile(r'^Extract app intents metadata\b', re.IGNORECASE),
2777
+ )
2778
+ _XCACTIVITYLOG_CATEGORY_RESOURCES_PATTERNS = (
2779
+ re.compile(r'^Compile asset catalog', re.IGNORECASE),
2780
+ re.compile(r'^CompileAssetCatalog\b'),
2781
+ re.compile(r'^CompileStoryboard\b'),
2782
+ re.compile(r'^CompileXIB\b'),
2783
+ re.compile(r'^CompileXCStrings\b'),
2784
+ # `LinkStoryboards` must be caught here, before the packaging
2785
+ # tuple's generic `^Link\b` rule claims it. The native →
2786
+ # resources → packaging precedence in `_classify_section_title`
2787
+ # is what keeps this working — do not reorder.
2788
+ re.compile(r'^LinkStoryboards\b'),
2789
+ re.compile(r'^CompileStrings\b'),
2790
+ re.compile(r'^ProcessInfoPlistFile\b'),
2791
+ re.compile(r'^CpResource\b'),
2792
+ re.compile(r'^CopyPlistFile\b'),
2793
+ re.compile(r'^CopyStringsFile\b'),
2794
+ re.compile(r'^CopyTiffFile\b'),
2795
+ re.compile(r'^CopyPNGFile\b'),
2796
+ re.compile(r'^GenerateAssetSymbols\b'),
2797
+ )
2798
+ _XCACTIVITYLOG_CATEGORY_PACKAGING_PATTERNS = (
2799
+ re.compile(r'^Link\b'),
2800
+ re.compile(r'^Ld\b'),
2801
+ re.compile(r'^CodeSign\b'),
2802
+ re.compile(r'^Sign \b'),
2803
+ re.compile(r'^SignManifestFile\b'),
2804
+ re.compile(r'^Strip\b'),
2805
+ re.compile(r'^Touch\b'),
2806
+ re.compile(r'^Embed\b'),
2807
+ re.compile(r'^ProcessProductPackaging\b'),
2808
+ re.compile(r'^RegisterExecutionPolicyException\b'),
2809
+ re.compile(r'^Validate\b'),
2810
+ re.compile(r'^GenerateDSYMFile\b'),
2811
+ re.compile(r'^CreateUniversalBinary\b'),
2812
+ # Swift stdlib embedding — `swift-stdlib-tool` copies the Swift
2813
+ # runtime dylibs into the app bundle. Conceptually packaging,
2814
+ # not compilation.
2815
+ re.compile(r'^Copy Swift standard libraries\b'),
2816
+ )
2817
+
2818
+
2819
+ def _find_derived_data_root(obj_root):
2820
+ """Walk up from `$OBJROOT` looking for the first ancestor that has
2821
+ a `Logs/Build/` subdirectory — that's Xcode's per-project
2822
+ DerivedData root. Returns the path, or None when not found.
2823
+
2824
+ `$OBJROOT` during `archive` typically resolves to
2825
+ `<DerivedData>/Build/Intermediates.noindex/ArchiveIntermediates/
2826
+ <SchemeName>/IntermediateBuildFilesPath`, so the ancestor walk
2827
+ usually crosses four or five directory levels before hitting the
2828
+ logs folder. Bounded by an explicit step cap so a malformed
2829
+ `$OBJROOT` never sends us walking to `/`.
2830
+ """
2831
+ if not obj_root:
2832
+ return None
2833
+ current = os.path.normpath(obj_root)
2834
+ for _ in range(10):
2835
+ candidate = os.path.join(current, 'Logs', 'Build')
2836
+ if os.path.isdir(candidate):
2837
+ return current
2838
+ parent = os.path.dirname(current)
2839
+ if parent == current:
2840
+ return None
2841
+ current = parent
2842
+ return None
2843
+
2844
+
2845
+ def _find_latest_xcactivitylog(obj_root):
2846
+ """Return the path to the newest `.xcactivitylog` Xcode wrote for
2847
+ this project, or None if the log directory can't be located.
2848
+
2849
+ "Newest" is resolved by mtime — Xcode writes one log file per
2850
+ build/archive, so the most-recently-modified one in `Logs/Build/`
2851
+ corresponds to the archive we're currently running under.
2852
+
2853
+ TODO(timings): Multi-arch archives (Mac Catalyst, iOS + macOS
2854
+ targets in one workspace) can place the relevant log in a sibling
2855
+ `Logs/Build/` directory we don't search. The current
2856
+ newest-mtime-in-the-discovered-dir strategy is deterministic but
2857
+ may pick the wrong arch's log on such projects. Cross-directory
2858
+ search deferred.
2859
+
2860
+ TODO(timings): Stale-log detection — if the newest `.xcactivitylog`
2861
+ was written before the current build started, we'd report
2862
+ pre-build numbers as if they were this build's. No retry / warning
2863
+ is emitted today; consider checking the file's mtime against
2864
+ `os.environ.get('BUILD_TIMESTAMP')` or similar before parsing.
2865
+ """
2866
+ dd_root = _find_derived_data_root(obj_root)
2867
+ if not dd_root:
2868
+ return None
2869
+ log_dir = os.path.join(dd_root, 'Logs', 'Build')
2870
+ try:
2871
+ entries = [
2872
+ os.path.join(log_dir, f)
2873
+ for f in os.listdir(log_dir)
2874
+ if f.endswith('.xcactivitylog')
2875
+ ]
2876
+ except OSError:
2877
+ return None
2878
+ if not entries:
2879
+ return None
2880
+ # Tie-break by filename so sibling logs with the same
2881
+ # second-granularity mtime (HFS+ filesystems, rsync-preserving-
2882
+ # mtime CI caches) don't shuffle non-deterministically. Xcode
2883
+ # encodes a monotonic timestamp at the start of each log's
2884
+ # UUID-based filename, so descending-by-name pairs well with
2885
+ # descending-by-mtime.
2886
+ entries.sort(
2887
+ key=lambda p: (os.path.getmtime(p), os.path.basename(p)),
2888
+ reverse=True,
2889
+ )
2890
+ return entries[0]
2891
+
2892
+
2893
+ def _tokenize_slf(data):
2894
+ """Tokenize an `SLF0` byte stream into a flat `[(type, value), …]`
2895
+ list. `data` is the DECOMPRESSED stream (the leading 4-byte `SLF0`
2896
+ header is skipped here).
2897
+
2898
+ Each token is `<ascii-payload><1-byte-delimiter>`:
2899
+ - `"` / `%` / `*` are LENGTH-PREFIXED: the payload is a DECIMAL
2900
+ length, then that many raw bytes follow and become the token's
2901
+ value (decoded as UTF-8, replacement on error). The single
2902
+ nastiest bug here is that the length is DECIMAL, NOT hex, and
2903
+ that `*` (JSON/data blob) MUST consume its length in bytes or
2904
+ the whole stream desyncs.
2905
+ - `^` is a double: 16 hex chars, little-endian IEEE-754. Value is
2906
+ the float, or None if malformed.
2907
+ - `#` / `@` are ints: HEX payload. `#` is a plain int, `@` is a
2908
+ 1-based class-table reference. Value is the parsed int.
2909
+ - `(` is an array/object header: HEX payload is the element
2910
+ count. Value is that int.
2911
+ - `-` is null. Value is None.
2912
+
2913
+ Returns `(tokens, desync_count)`. `desync_count` is the number of
2914
+ malformed length/number payloads encountered — non-zero hints the
2915
+ grammar drifted, but parsing continues best-effort.
2916
+ """
2917
+ tokens = []
2918
+ desync = 0
2919
+ i = 4 # skip the 4-byte 'SLF0' header
2920
+ n = len(data)
2921
+ payload = bytearray()
2922
+ while i < n:
2923
+ b = data[i]
2924
+ if b in _SLF_DELIMS:
2925
+ t = chr(b)
2926
+ p = payload.decode('ascii', 'replace')
2927
+ payload = bytearray()
2928
+ i += 1
2929
+ if b in _SLF_LEN_PREFIXED_SET:
2930
+ # DECIMAL length, then that many raw bytes.
2931
+ try:
2932
+ ln = int(p, 10) if p else 0
2933
+ except ValueError:
2934
+ ln = 0
2935
+ desync += 1
2936
+ tokens.append((t, data[i:i + ln].decode('utf-8', 'replace')))
2937
+ i += ln
2938
+ elif t == '^':
2939
+ if len(p) == 16:
2940
+ try:
2941
+ tokens.append((t, struct.unpack('<d', bytes.fromhex(p))[0]))
2942
+ except Exception:
2943
+ tokens.append((t, None))
2944
+ desync += 1
2945
+ else:
2946
+ tokens.append((t, None))
2947
+ else:
2948
+ # '#', '@', '(' carry a HEX payload; '-' carries none.
2949
+ try:
2950
+ tokens.append((t, int(p, 16) if p else 0))
2951
+ except ValueError:
2952
+ tokens.append((t, None))
2953
+ desync += 1
2954
+ else:
2955
+ payload.append(b)
2956
+ i += 1
2957
+ return tokens, desync
2958
+
2959
+
2960
+ def _extract_slf_sections(tokens):
2961
+ """Walk a tokenized SLF stream and extract every section instance
2962
+ as a `(cls, title, start_cf, end_cf)` tuple.
2963
+
2964
+ The class table is every `%` className-def in stream order, 1-based;
2965
+ an `@N` token references the N-th def. A section instance starts at
2966
+ an `@`-ref to one of the two section classes, immediately followed
2967
+ by tokens of types `# " " " ^ ^` (sectionType, domainType, title,
2968
+ signature, timeStarted, timeStopped) and then a subSections field
2969
+ that is `(` (array) or `-` (none). We read:
2970
+ - `cls` — the referenced class name.
2971
+ - `title` — the 3rd string (index +3).
2972
+ - `start_cf` — the 1st double (index +5), CFAbsoluteTime seconds.
2973
+ - `end_cf` — the 2nd double (index +6), CFAbsoluteTime seconds.
2974
+
2975
+ Sections with a missing / non-finite / inverted timestamp pair are
2976
+ dropped (they can't contribute to span, occupancy, or the Gantt).
2977
+
2978
+ No tree reconstruction is attempted: containment is resolved later
2979
+ by interval math, which is robust to the subSection-count drift
2980
+ that an explicit child-count stack walk is prone to on real logs.
2981
+ """
2982
+ class_table = [v for (t, v) in tokens if t == '%']
2983
+ ntok = len(tokens)
2984
+
2985
+ def class_of(idx):
2986
+ if isinstance(idx, int) and 1 <= idx <= len(class_table):
2987
+ return class_table[idx - 1]
2988
+ return None
2989
+
2990
+ sections = []
2991
+ for k in range(ntok - 7):
2992
+ t, v = tokens[k]
2993
+ if t != '@':
2994
+ continue
2995
+ cls = class_of(v)
2996
+ if cls not in _SLF_SECTION_CLASSES:
2997
+ continue
2998
+ # Shape check: # " " " ^ ^ then ( or - .
2999
+ if (tokens[k + 1][0] != '#' or tokens[k + 2][0] != '"'
3000
+ or tokens[k + 3][0] != '"' or tokens[k + 4][0] != '"'
3001
+ or tokens[k + 5][0] != '^' or tokens[k + 6][0] != '^'
3002
+ or tokens[k + 7][0] not in ('(', '-')):
3003
+ continue
3004
+ title = tokens[k + 3][1]
3005
+ start_cf = tokens[k + 5][1]
3006
+ end_cf = tokens[k + 6][1]
3007
+ if start_cf is None or end_cf is None:
3008
+ continue
3009
+ if not (math.isfinite(start_cf) and math.isfinite(end_cf)):
3010
+ continue
3011
+ if end_cf < start_cf:
3012
+ continue
3013
+ sections.append((cls, title, start_cf, end_cf))
3014
+ return sections
3015
+
3016
+
3017
+ def _interval_union_seconds(intervals):
3018
+ """Total length (seconds) of the UNION of `[start, end]` intervals
3019
+ — the OCCUPANCY metric. Overlapping windows are merged, so the
3020
+ result can never exceed wall-clock. This is the fix for the flat
3021
+ code's per-category SUM, which double-counted Swift batch windows
3022
+ replicated onto every file's command section.
3023
+
3024
+ `intervals` is an iterable of `(start, end)` float pairs (any
3025
+ order). Returns 0.0 for an empty input.
3026
+ """
3027
+ ordered = sorted(intervals)
3028
+ total = 0.0
3029
+ cur_start = cur_end = None
3030
+ for s, e in ordered:
3031
+ if cur_start is None:
3032
+ cur_start, cur_end = s, e
3033
+ elif s <= cur_end:
3034
+ if e > cur_end:
3035
+ cur_end = e
3036
+ else:
3037
+ total += cur_end - cur_start
3038
+ cur_start, cur_end = s, e
3039
+ if cur_start is not None:
3040
+ total += cur_end - cur_start
3041
+ return total
3042
+
3043
+
3044
+ def _classify_section_title(title):
3045
+ """Map a command-invocation section title to one of the cross-
3046
+ platform category buckets: `'native'`, `'resources'`,
3047
+ `'packaging'`, `'other'`. (iOS never emits `'managed_code'` — see
3048
+ the mapping docstring above the pattern tuples.) Returns `None`
3049
+ only for an empty title. Precedence-ordered regex scan — first
3050
+ match wins.
3051
+
3052
+ Callers pass COMMAND-invocation titles (`IDEActivityLogCommand-
3053
+ InvocationSection`), never the grouping wrappers, so there is no
3054
+ longer a wrapper-title escape hatch here: the SLF section-tree
3055
+ walk already separates the two classes, and per-category OCCUPANCY
3056
+ (interval union over command sections) is what feeds the chips,
3057
+ so a parallel build's occupancy can never exceed wall-clock
3058
+ `total_ms`. The dominant-category logic in `_build_timeline_blob`
3059
+ reuses this same classifier on the commands contained within each
3060
+ target window.
3061
+ """
3062
+ if not title:
3063
+ return None
3064
+ # `Compiling Clang module <name>` is a real native compile event
3065
+ # (explicit module build); list it ahead of the generic patterns
3066
+ # so it lands in `native` rather than `other`.
3067
+ if title.startswith('Compiling Clang module'):
3068
+ return 'native'
3069
+ for p in _XCACTIVITYLOG_CATEGORY_NATIVE_PATTERNS:
3070
+ if p.match(title):
3071
+ return 'native'
3072
+ for p in _XCACTIVITYLOG_CATEGORY_RESOURCES_PATTERNS:
3073
+ if p.match(title):
3074
+ return 'resources'
3075
+ for p in _XCACTIVITYLOG_CATEGORY_PACKAGING_PATTERNS:
3076
+ if p.match(title):
3077
+ return 'packaging'
3078
+ return 'other'
3079
+
3080
+
3081
+ # `/Users/<name>` matches the username token regardless of what
3082
+ # follows. Earlier we anchored on `(?=/|$)` so the lookahead would
3083
+ # require either a path separator or end-of-string — but Xcode
3084
+ # occasionally embeds bare user-home references in the middle of
3085
+ # titles (e.g. `"warning at /Users/alice and exit"`), and the
3086
+ # anchored form let the username slip past, where the catch-all
3087
+ # `_ABSOLUTE_PATH_RE` then reduced `/Users/alice` to basename
3088
+ # `alice` — emitting the bare username as a standalone word.
3089
+ #
3090
+ # Username character class is restricted to `[A-Za-z0-9._\-]+` (the
3091
+ # realistic Unix-username surface) rather than `[^/\s]+`. The broader
3092
+ # negation would also consume trailing punctuation (`,`, `)`, `:`,
3093
+ # `;`, `.`) when the username appears mid-title, producing slightly
3094
+ # mangled surrounding text in the emitted name. The narrower class
3095
+ # stops at the first non-username character so the surrounding
3096
+ # sentence preserves its shape (`"see /Users/alice)"` →
3097
+ # `"see <home>)"`, not `"see <home>"`).
3098
+ _PATH_USER_HOME_RE = re.compile(r'/Users/[A-Za-z0-9._\-]+')
3099
+ _PATH_PRIVATE_VAR_FOLDERS_RE = re.compile(
3100
+ r'/private/var/folders/[^\s]+'
3101
+ )
3102
+ _ABSOLUTE_PATH_RE = re.compile(r'(?<!\S)(/[^\s]+)')
3103
+
3104
+
3105
+ def _sanitize_section_title_for_emission(title):
3106
+ """Strip PII (username, machine-local paths) out of an xcactivitylog
3107
+ section title before it ships in `top_tasks`.
3108
+
3109
+ Xcode often embeds absolute paths in section titles (e.g.
3110
+ `Compile /Users/alice/Projects/MyApp/Sources/Foo.swift`). We:
3111
+ - replace `/Users/<name>/` with `<home>/` (drops the username),
3112
+ - collapse `/private/var/folders/...` paths to their basename
3113
+ (TemporaryItems / DerivedData scratch paths leak machine UUIDs),
3114
+ - reduce any other absolute path token to its basename.
3115
+ Non-path tokens pass through unchanged so titles like
3116
+ `Compile Foo.swift (arm64)` are emitted verbatim.
3117
+ """
3118
+ if not title:
3119
+ return title
3120
+
3121
+ def _replace_user_home(m):
3122
+ return '<home>'
3123
+
3124
+ def _replace_private_var(m):
3125
+ return os.path.basename(m.group(0))
3126
+
3127
+ def _replace_absolute(m):
3128
+ path = m.group(1)
3129
+ if path.startswith('<home>'):
3130
+ return path
3131
+ return os.path.basename(path) or path
3132
+
3133
+ sanitized = _PATH_USER_HOME_RE.sub(_replace_user_home, title)
3134
+ sanitized = _PATH_PRIVATE_VAR_FOLDERS_RE.sub(_replace_private_var, sanitized)
3135
+ sanitized = _ABSOLUTE_PATH_RE.sub(_replace_absolute, sanitized)
3136
+ return sanitized
3137
+
3138
+
3139
+ def _dominant_category_for_window(commands, win_start, win_end):
3140
+ """Return the DOMINANT category for a target window — the category
3141
+ with the greatest command-OCCUPANCY (interval union, not sum) among
3142
+ the command-invocations whose `[start, end]` is CONTAINED within
3143
+ `[win_start, win_end]`.
3144
+
3145
+ Containment (rather than overlap) keeps each command attributed to
3146
+ exactly the target it belongs to; a command that straddles a target
3147
+ boundary (rare, and usually a build-system bookkeeping section) is
3148
+ simply not counted toward that target's category.
3149
+
3150
+ `commands` is a list of `(category, start_cf, end_cf)` tuples for
3151
+ every classified command-invocation in the build. Returns `'native'`
3152
+ when the window contains no classifiable command (a target made up
3153
+ of only build-graph bookkeeping still renders as a native bar rather
3154
+ than vanishing).
3155
+ """
3156
+ by_cat = {}
3157
+ for cat, s, e in commands:
3158
+ if s >= win_start and e <= win_end:
3159
+ by_cat.setdefault(cat, []).append((s, e))
3160
+ if not by_cat:
3161
+ return 'native'
3162
+ best_cat = 'native'
3163
+ best_occ = -1.0
3164
+ for cat, intervals in by_cat.items():
3165
+ occ = _interval_union_seconds(intervals)
3166
+ if occ > best_occ:
3167
+ best_occ = occ
3168
+ best_cat = cat
3169
+ return best_cat
3170
+
3171
+
3172
+ def _build_timeline_blob(targets, build_start_cf):
3173
+ """Build the Gantt-chart DETAIL blob from the per-target grouping
3174
+ records. `targets` is a list of `(path, category, start_cf, end_cf)`
3175
+ tuples — one per `Build target ` grouping, with `category` already
3176
+ resolved to the target's DOMINANT command-category — and
3177
+ `build_start_cf` is the build's earliest section start in
3178
+ CFAbsoluteTime.
3179
+
3180
+ Wire shape (unchanged from the flat implementation, and mirroring
3181
+ the Android Gradle plugin's `TimingsPayloadSerializer` so back-end
3182
+ + front-end render both platforms from one schema):
3183
+
3184
+ { "schema_version": 1,
3185
+ "build_started_at_ms": <epoch ms of build start>,
3186
+ "wall_clock_ms": <max end − min start over targets, ms>,
3187
+ "tasks": [ {"path", "category", "start_ms", "end_ms"}, … ] }
3188
+
3189
+ Rules:
3190
+ - `start_ms`/`end_ms` are OFFSETS from `build_started_at_ms`
3191
+ (not absolute), in ms.
3192
+ - tasks sorted by `start_ms` ascending.
3193
+ - capped at `_TIMELINE_MAX_TASKS`; when truncating we keep the
3194
+ SLOWEST targets, then re-sort the kept slice by start.
3195
+
3196
+ Unlike the old code, the rows are the per-target GROUPINGS — NOT
3197
+ per-file leaf sections — so the Gantt shows ~one bar per target
3198
+ (low-double-digit peak concurrency) rather than 300+ phantom
3199
+ parallel bars. The mega-wrappers (root build / `Prepare build` /
3200
+ `Run post-actions` / `Prepare packages`) were already filtered out
3201
+ by the caller.
3202
+
3203
+ Returns the blob dict, or None when there are no target groupings —
3204
+ callers then PUT nothing.
3205
+ """
3206
+ if not targets:
3207
+ return None
3208
+
3209
+ # `build_started_at_ms` is anchored to the WHOLE build's start (the
3210
+ # earliest section of any class), so per-target offsets are relative
3211
+ # to t0 of the build, not to the earliest target.
3212
+ build_started_at_ms = int(round(
3213
+ (build_start_cf + _CF_ABSOLUTE_TIME_EPOCH_OFFSET) * 1000))
3214
+ latest_end_cf = max(e for _p, _c, _s, e in targets)
3215
+ wall_clock_ms = max(0, int(round((latest_end_cf - build_start_cf) * 1000)))
3216
+
3217
+ # Truncation keeps the SLOWEST targets (sort by duration desc,
3218
+ # slice), then re-sorts the kept slice chronologically so the array
3219
+ # reads start-ordered for the front-end's greedy-lane Gantt packer.
3220
+ if len(targets) > _TIMELINE_MAX_TASKS:
3221
+ print(
3222
+ "Bugsee: build-timings detail blob truncated "
3223
+ "(%d targets -> %d slowest kept)"
3224
+ % (len(targets), _TIMELINE_MAX_TASKS)
3225
+ )
3226
+ kept = sorted(
3227
+ targets, key=lambda t: t[3] - t[2], reverse=True
3228
+ )[:_TIMELINE_MAX_TASKS]
3229
+ else:
3230
+ kept = targets
3231
+
3232
+ tasks = [
3233
+ {
3234
+ 'path': _sanitize_section_title_for_emission(path)[:255],
3235
+ 'category': category,
3236
+ 'start_ms': int(round((start_cf - build_start_cf) * 1000)),
3237
+ 'end_ms': int(round((end_cf - build_start_cf) * 1000)),
3238
+ }
3239
+ for path, category, start_cf, end_cf in sorted(kept, key=lambda t: t[2])
3240
+ ]
3241
+
3242
+ return {
3243
+ 'schema_version': _TIMELINE_SCHEMA_VERSION,
3244
+ 'build_started_at_ms': build_started_at_ms,
3245
+ 'wall_clock_ms': wall_clock_ms,
3246
+ 'tasks': tasks,
3247
+ }
3248
+
3249
+
3250
+ def _timeline_blob_gz(blob):
3251
+ """Gzip the compact JSON timeline blob, mirroring `_deps_blob_gz`
3252
+ (compact separators, `gzip.compress`). Shape matches the Android
3253
+ Gradle plugin's `TimingsPayloadSerializer.gzipBytes`."""
3254
+ raw = json.dumps(blob, separators=(',', ':')).encode('utf-8')
3255
+ return gzip.compress(raw)
3256
+
3257
+
3258
+ def _parse_xcactivitylog(log_path):
3259
+ """Extract the build's wall-clock span, per-category OCCUPANCY, a
3260
+ top-N slowest-targets list, AND a per-target Gantt timeline from an
3261
+ `.xcactivitylog`, using a proper SLF section-tree decode.
3262
+
3263
+ Pipeline:
3264
+ 1. Decompress + `_tokenize_slf` the `SLF0` stream.
3265
+ 2. `_extract_slf_sections` → every `(cls, title, start, end)`
3266
+ section instance, split into the two section classes.
3267
+ 3. `total_ms` = wall-clock SPAN (`max(end) − min(start)`)
3268
+ over ALL sections.
3269
+ 4. `category_sums` = per-category OCCUPANCY (interval union) over
3270
+ the COMMAND-invocation sections, classified
3271
+ by title. Occupancy can never exceed
3272
+ wall-clock — the fix for the flat code's
3273
+ batch-inflated SUMs.
3274
+ 5. targets = the `IDEActivityLogSection` groupings whose
3275
+ title starts with `Build target ` (the
3276
+ mega-wrappers are excluded). Each target's
3277
+ DOMINANT category is the category with the
3278
+ greatest command-occupancy among commands
3279
+ contained within its window.
3280
+ 6. `top_tasks` = the slowest targets (name + duration_ms),
3281
+ capped at `_XCACTIVITYLOG_TOP_N`.
3282
+ 7. `timeline` = the Gantt blob (one row per target).
3283
+
3284
+ Returns a dict with `total_ms` / `top_tasks` / `category_sums` /
3285
+ `timeline`, or None when the log can't be opened / decompressed /
3286
+ tokenized into any section at all.
3287
+ """
3288
+ try:
3289
+ with gzip.open(log_path, 'rb') as g:
3290
+ data = g.read(_XCACTIVITYLOG_MAX_DECOMPRESSED + 1)
3291
+ except Exception:
3292
+ return None
3293
+
3294
+ if len(data) > _XCACTIVITYLOG_MAX_DECOMPRESSED:
3295
+ # Truncated to cap memory; a truncated tail loses late sections
3296
+ # anyway. Work with what we have — the class table and the root
3297
+ # section live at the head of the stream.
3298
+ data = data[:_XCACTIVITYLOG_MAX_DECOMPRESSED]
3299
+
3300
+ tokens, _desync = _tokenize_slf(data)
3301
+ sections = _extract_slf_sections(tokens)
3302
+ if not sections:
3303
+ return None
3304
+
3305
+ # total_ms = wall-clock SPAN over ALL sections. CFAbsoluteTime is
3306
+ # seconds since 2001; the span is offset-independent so no epoch
3307
+ # shift is needed here.
3308
+ build_start_cf = min(s for _c, _t, s, _e in sections)
3309
+ build_end_cf = max(e for _c, _t, _s, e in sections)
3310
+ total_ms = max(0, int(round((build_end_cf - build_start_cf) * 1000)))
3311
+
3312
+ # Classify each COMMAND-invocation once; reused for both the
3313
+ # category-occupancy chips and the per-target dominant category.
3314
+ commands = []
3315
+ for cls, title, s, e in sections:
3316
+ if cls != _SLF_COMMAND_CLASS:
3317
+ continue
3318
+ bucket = _classify_section_title(title)
3319
+ if bucket is None:
3320
+ continue
3321
+ commands.append((bucket, s, e))
3322
+
3323
+ # Per-category OCCUPANCY (interval union per bucket), in ms.
3324
+ # `managed_code` is a cross-platform bucket iOS never fills (Swift /
3325
+ # Obj-C / C/C++ all land in `native`); kept pre-seeded to zero for
3326
+ # parity with Android, and the emission loop drops zero buckets.
3327
+ by_cat_intervals = {}
3328
+ for bucket, s, e in commands:
3329
+ by_cat_intervals.setdefault(bucket, []).append((s, e))
3330
+ category_sums = {
3331
+ 'managed_code': 0,
3332
+ 'native': 0,
3333
+ 'resources': 0,
3334
+ 'packaging': 0,
3335
+ 'other': 0,
3336
+ }
3337
+ for bucket, intervals in by_cat_intervals.items():
3338
+ category_sums[bucket] = int(round(
3339
+ _interval_union_seconds(intervals) * 1000))
3340
+
3341
+ # `Build target ` groupings → the Gantt rows + top_tasks. The four
3342
+ # mega-wrappers (root build, `Prepare build`, `Prepare packages`,
3343
+ # `Run post-actions`) are `IDEActivityLogSection` too but DON'T
3344
+ # carry the `Build target ` prefix, so they're excluded — they span
3345
+ # the whole build and would draw a single full-width bar.
3346
+ targets = []
3347
+ for cls, title, s, e in sections:
3348
+ if cls != _SLF_SECTION_CLASS:
3349
+ continue
3350
+ if not title or not title.startswith(_XCACTIVITYLOG_TARGET_PREFIX):
3351
+ continue
3352
+ category = _dominant_category_for_window(commands, s, e)
3353
+ # Strip the `Build target ` prefix for the emitted path.
3354
+ path = title[len(_XCACTIVITYLOG_TARGET_PREFIX):] or title
3355
+ targets.append((path, category, s, e))
3356
+
3357
+ # top_tasks = slowest target groupings, longest first, capped at
3358
+ # TOP_N. Durations here are the target wall-clock windows (NOT
3359
+ # batch-inflated per-file sums).
3360
+ top_sorted = sorted(
3361
+ targets, key=lambda t: t[3] - t[2], reverse=True)
3362
+ top_tasks = []
3363
+ for path, _category, s, e in top_sorted[:_XCACTIVITYLOG_TOP_N]:
3364
+ dur = int(round((e - s) * 1000))
3365
+ if dur < 1: # sub-millisecond targets are noise
3366
+ continue
3367
+ top_tasks.append({
3368
+ 'name': _sanitize_section_title_for_emission(path)[:255],
3369
+ 'duration_ms': dur,
3370
+ })
3371
+
3372
+ # Per-target Gantt blob. Offsets are relative to the WHOLE build's
3373
+ # start so the bars line up with the absolute timeline.
3374
+ timeline = _build_timeline_blob(targets, build_start_cf)
3375
+
3376
+ return {
3377
+ 'total_ms': total_ms,
3378
+ 'top_tasks': top_tasks,
3379
+ 'category_sums': category_sums,
3380
+ 'timeline': timeline,
3381
+ }
3382
+
3383
+
3384
+ def resolve_build_timings(env):
3385
+ """Produce the build's timing data for the current build.
3386
+
3387
+ Returns a `(timings, timeline_gz)` tuple:
3388
+ - `timings`: the inline `build_metadata.timings` sub-object —
3389
+ `total_ms` (wall-clock SPAN), a `top_tasks` list of the
3390
+ slowest `Build target ` groupings, and a per-category rollup
3391
+ (`native_ms` / `resources_ms` / `packaging_ms` / `other_ms`)
3392
+ whose values are command-invocation OCCUPANCY (interval union,
3393
+ so they never exceed `total_ms`). iOS never emits
3394
+ `managed_code_ms` — Swift / Obj-C / C / C++ all compile into
3395
+ the Mach-O and land in `native_ms`. The server schema shares
3396
+ the field names with Android, which keeps `managed_code_ms`
3397
+ populated (JVM-bytecode compilation: kotlinc / javac / R8 /
3398
+ desugar).
3399
+ - `timeline_gz`: the gzipped per-target Gantt DETAIL blob PUT to
3400
+ the presigned `timings_upload_endpoint` for Gantt-chart parity
3401
+ with Android's `timings.json`, or None when there's no
3402
+ `Build target ` grouping to chart.
3403
+
3404
+ Either element may be None independently. `(None, None)` means no
3405
+ timing source was available at all.
3406
+
3407
+ Wraps the entire pipeline in a broad except so any future parser
3408
+ bug (e.g. an `inf`/`nan` slipping past the section filters and
3409
+ overflowing duration arithmetic, or a malformed SLF stream
3410
+ causing struct/unicode decode errors that weren't anticipated)
3411
+ degrades gracefully to "no timings" instead of escaping into the
3412
+ outer build-publish wrapper. The outer wrapper aborts the entire
3413
+ build-info upload — losing timings is acceptable; losing the
3414
+ build record is not.
3415
+ """
3416
+ try:
3417
+ return _resolve_build_timings_impl(env)
3418
+ except Exception as e:
3419
+ print(
3420
+ "Bugsee: build-timings extraction failed (%s) — "
3421
+ "omitting timings from build_metadata" % str(e)
3422
+ )
3423
+ return None, None
3424
+
3425
+
3426
+ def _resolve_build_timings_impl(env):
3427
+ obj_root = env.get('OBJROOT')
3428
+ log_path = _find_latest_xcactivitylog(obj_root)
3429
+ if not log_path:
3430
+ return None, None
3431
+ parsed = _parse_xcactivitylog(log_path)
3432
+ if not parsed:
3433
+ return None, None
3434
+
3435
+ # Server sanitizer drops fields that are falsy / None, but being
3436
+ # explicit about omissions keeps the wire payload tidy. Emit
3437
+ # zero-valued category sums as positive zeros so "no data" stays
3438
+ # distinguishable from "genuinely zero time in this category" on
3439
+ # the server — the sanitizer preserves zero-valued ints.
3440
+ timings = {}
3441
+ if parsed.get('total_ms'):
3442
+ timings['total_ms'] = parsed['total_ms']
3443
+ if parsed.get('top_tasks'):
3444
+ timings['top_tasks'] = parsed['top_tasks']
3445
+ for bucket, value in (parsed.get('category_sums') or {}).items():
3446
+ if value > 0:
3447
+ timings['%s_ms' % bucket] = value
3448
+
3449
+ # Per-task timeline DETAIL blob — gzipped only when it carries at
3450
+ # least one task, so the caller never sets `request_timings_upload`
3451
+ # nor PUTs an empty blob. Mirrors the deps-blob "summary + blob"
3452
+ # split exactly.
3453
+ timeline = parsed.get('timeline')
3454
+ timeline_gz = None
3455
+ if timeline and timeline.get('tasks'):
3456
+ timeline_gz = _timeline_blob_gz(timeline)
3457
+
3458
+ return (timings or None), timeline_gz
3459
+
3460
+
3461
+ # -----------------------------------------------------------------
3462
+ # Build-publish flow + gate
3463
+ # -----------------------------------------------------------------
3464
+ #
3465
+ # The flow has two layers:
3466
+ #
3467
+ # - **build-info** (default ON, release-only): every Release archive
3468
+ # posts a build record to the Bugsee back-end carrying version,
3469
+ # build, package_id, VCS, build-machine, plugin / Xcode / SDK
3470
+ # versions, timings, and the artefact's file size. The record
3471
+ # unlocks crash-context enrichment (commit lookup) and serves as
3472
+ # the in-build size-check baseline. Disable via
3473
+ # `BUGSEE_BUILD_INFO_ENABLED=0` for firewalled CI / privacy-
3474
+ # sensitive builds.
3475
+ #
3476
+ # - **size-analysis** (default OFF, sub-feature of build-info):
3477
+ # when enabled, the build-info POST also asks the server for a
3478
+ # presigned PUT URL and ships the artefact bytes for server-side
3479
+ # tree analysis. Requires build-info to be enabled — the flow
3480
+ # warns and skips both if size-analysis is on while build-info
3481
+ # is off.
3482
+
3483
+
3484
+ def _env_truthy_default_true(value):
3485
+ """`True` for missing / unset / empty values (treated as "default
3486
+ on") AND for conventional truthy tokens. Used by the build-info
3487
+ gate where the default is ON.
3488
+
3489
+ Empty string is treated as missing: many GUIs (Xcode's "Add
3490
+ Environment Variable", some CI dashboards) emit `BUGSEE_…=""`
3491
+ when the user leaves the value field blank. Treating `""` as
3492
+ "off" would silently flip a user who *thought* they were
3493
+ accepting the default into the disabled path — confusing and
3494
+ invisible."""
3495
+ if value is None:
3496
+ return True
3497
+ if not value.strip():
3498
+ return True
3499
+ return _env_truthy(value)
3500
+
3501
+
3502
+
3503
+
209
3504
  def main():
3505
+ # Symbols-pipeline entry. The deps pipeline is now invoked
3506
+ # separately from the post-daemonization slot at module level
3507
+ # (see the `if __name__ == "__main__":` block) so it runs
3508
+ # regardless of whether the build has dSYM symbols to upload.
3509
+ #
3510
+ # As of the bugsee-cli migration, the actual dSYM upload is
3511
+ # performed by the bugsee-cli Rust binary, one invocation per
3512
+ # `.dSYM` bundle. This loop's role is:
3513
+ # - discover .dSYM bundles under options.dsym_folder
3514
+ # - extract Mach-O UUIDs (via dwarfdump) to short-circuit
3515
+ # dSYMs whose UUIDs are already in ~/.bugseeUploadList
3516
+ # - apply BCSymbolMap deobfuscation in place when --maps is set
3517
+ # - hand each surviving .dSYM directory to the CLI
3518
+ # The CLI itself re-extracts UUIDs internally via
3519
+ # `symbolic-debuginfo` and POSTs `{version, build}` to
3520
+ # /apps/<token>/symbols — the server then walks the uploaded
3521
+ # tarball and stores `images[]` per arch slice. Dashboard
3522
+ # outcome is one symbol record per .dSYM (finer granularity
3523
+ # than the legacy one-record-per-build zip).
210
3524
  tempDir = tempfile.mkdtemp()
211
3525
  print("Processing in " + tempDir)
212
- zipFileLocation = os.path.join(tempDir, 'symbols.zip')
213
- dwarfs = []
3526
+ # List of (dsym_bundle_dir, uuids_discovered) — one entry per
3527
+ # .dSYM the CLI needs to upload.
3528
+ dsymUploads = []
214
3529
  uploadedImages = loadUploadedList()
215
3530
 
3531
+ # Track the first .dSYM zip filename (--list mode) so the legacy
3532
+ # `-{version}-{build}.dSYM.zip` pattern can supply the version/build
3533
+ # fallback. Used only when neither --version/--build nor Info.plist
3534
+ # produced values.
3535
+ firstDsymZipForFallback = None
216
3536
  if options.dsym_list:
217
3537
  options.dsym_folder = tempDir
218
3538
  for f in args[1:]:
@@ -220,48 +3540,93 @@ def main():
220
3540
  continue
221
3541
  if (os.stat(f).st_size == 0):
222
3542
  continue
3543
+ if firstDsymZipForFallback is None:
3544
+ firstDsymZipForFallback = f
223
3545
  with zipfile.ZipFile(f, 'r') as zipf:
224
- zipf.extractall(tempDir)
3546
+ zipf.extractall(tempDir)
3547
+
3548
+ # Resolve version + build with the legacy fallback chain. Order:
3549
+ # 1. --version / --build CLI flags (or BUGSEE_APP_{VERSION,BUILD} env)
3550
+ # 2. --list mode: `-{version}-{build}.dSYM.zip` filename pattern
3551
+ # 3. Otherwise: CFBundleShortVersionString + CFBundleVersion from
3552
+ # $INFOPLIST_PATH (the Xcode build-phase default — users rarely
3553
+ # pass -v/-b in that context).
3554
+ # Without resolved values the bugsee-cli exec would error out, since
3555
+ # `--version` and `--build` are required positional args on the CLI.
3556
+ resolvedVersion = options.version
3557
+ resolvedBuild = options.build
3558
+ if not (resolvedVersion and resolvedBuild):
3559
+ fallbackVersion, fallbackBuild = getVersionAndBuild(firstDsymZipForFallback or "")
3560
+ if not resolvedVersion:
3561
+ resolvedVersion = fallbackVersion
3562
+ if not resolvedBuild:
3563
+ resolvedBuild = fallbackBuild
225
3564
 
226
3565
  os.chdir(options.dsym_folder)
227
3566
  for root, dirs, files in os.walk(options.dsym_folder):
228
3567
  if not root.endswith('dSYM/Contents/Resources/DWARF'):
229
3568
  continue
230
3569
 
3570
+ # The .dSYM directory is three components up from
3571
+ # `<bundle>.dSYM/Contents/Resources/DWARF`.
3572
+ dsymDir = os.path.dirname(os.path.dirname(os.path.dirname(root)))
231
3573
  print(root)
3574
+
3575
+ newImagesInDsym = []
232
3576
  for f in files:
233
- if (os.path.islink(os.path.join(root, f))):
3577
+ filePath = os.path.join(root, f)
3578
+ if os.path.islink(filePath):
234
3579
  continue
235
- if (os.stat(os.path.join(root, f)).st_size == 0):
3580
+ if os.stat(filePath).st_size == 0:
236
3581
  continue
237
- images = parseDSYM(os.path.join(root, f))
238
- if (len(images) == 0):
3582
+ images = parseDSYM(filePath)
3583
+ if len(images) == 0:
239
3584
  continue
240
3585
  if isInUploadedList(images, uploadedImages):
241
3586
  print("Already uploaded %s, skipping" % f)
242
3587
  continue
243
3588
  if options.symbol_maps:
244
- deobfuscateDSYM(os.path.join(root, f), options.symbol_maps)
245
- dwarfs.append(os.path.join(root, f))
246
- uploadedImages.extend(images)
247
-
248
- if len(dwarfs) > 0:
249
- with zipfile.ZipFile(zipFileLocation, 'w', zipfile.ZIP_DEFLATED) as zipf:
250
- for dwarf in dwarfs:
251
- zipf.write(dwarf, os.path.relpath(dwarf, options.dsym_folder), zipfile.ZIP_DEFLATED)
3589
+ deobfuscateDSYM(filePath, options.symbol_maps)
3590
+ newImagesInDsym.extend(images)
252
3591
 
253
- icon = getIcon()
254
- if icon:
255
- icon = uncrushIcon(icon, tempDir)
256
- if icon and os.path.isfile(icon):
257
- zipf.write(icon, 'icon.png', zipfile.ZIP_DEFLATED)
258
-
259
- zipf.close()
260
-
261
- result = uploadZipFile(zipFileLocation)
262
- if result:
263
- saveUploadedList(uploadedImages)
3592
+ if newImagesInDsym:
3593
+ dsymUploads.append((dsymDir, newImagesInDsym))
264
3594
 
3595
+ if len(dsymUploads) > 0:
3596
+ if not (resolvedVersion and resolvedBuild):
3597
+ # CLI requires both. Bailing here avoids burning N CLI execs
3598
+ # only to have each fail the same way at argv parsing.
3599
+ print("Bugsee: could not determine version/build (none of "
3600
+ "--version/--build, --list-mode filename pattern, or "
3601
+ "$INFOPLIST_PATH produced values); %d dSYM(s) not "
3602
+ "uploaded" % len(dsymUploads))
3603
+ else:
3604
+ cliBinary = resolveCli(
3605
+ cliPath=options.cli_path,
3606
+ cliVersion=options.cli_version)
3607
+ if cliBinary is None:
3608
+ # resolveCli already explained why on stderr/stdout. Skip
3609
+ # uploads; do NOT mark UUIDs as uploaded — next run will
3610
+ # retry once a usable binary is available.
3611
+ print("Bugsee: no bugsee-cli available; %d dSYM(s) not uploaded" % len(dsymUploads))
3612
+ else:
3613
+ successCount = 0
3614
+ for dsymDir, images in dsymUploads:
3615
+ ok = uploadDsymViaCli(
3616
+ cliBinary,
3617
+ dsymDir,
3618
+ APP_TOKEN,
3619
+ options.endpoint,
3620
+ resolvedVersion,
3621
+ resolvedBuild,
3622
+ )
3623
+ if ok:
3624
+ successCount += 1
3625
+ uploadedImages.extend(images)
3626
+ if successCount > 0:
3627
+ saveUploadedList(uploadedImages)
3628
+ print("Bugsee: uploaded %d/%d dSYM(s) via bugsee-cli" % (
3629
+ successCount, len(dsymUploads)))
265
3630
 
266
3631
  # cleanup
267
3632
  shutil.rmtree(tempDir, ignore_errors=True)
@@ -286,34 +3651,248 @@ if __name__ == "__main__":
286
3651
  help="Set the version of the application dSYM corresponds to")
287
3652
  parser.add_option("-d", "--build_dir", dest="build_dir",
288
3653
  help="Use for custom TARGET_BUILD_DIR", default=os.environ.get('TARGET_BUILD_DIR'))
3654
+ # Dependencies-collection toggle. On by default — registers
3655
+ # the build with Bugsee and uploads a per-build deps blob
3656
+ # parsed from Podfile.lock / Package.resolved / Cartfile.resolved
3657
+ # / linked vendored frameworks. Disable with --no-deps if the
3658
+ # consumer wants the legacy symbols-only flow OR if the Bugsee
3659
+ # iOS SDK's tools.bundle/BugseeAgent build phase already
3660
+ # handled deps collection for this build (the fastlane Ruby
3661
+ # action checks the cross-producer handshake and passes
3662
+ # --no-deps when it detects the other producer did the work).
3663
+ parser.add_option("--no-deps", dest="collect_deps", action="store_false",
3664
+ default=True,
3665
+ help="Skip the dependencies collection + upload step. "
3666
+ "Symbols + timings uploads still run unless "
3667
+ "disabled separately.")
3668
+ # Timings-collection toggle. On by default. Independent of
3669
+ # --no-deps so the cross-producer handshake can disable each
3670
+ # action individually. When the Bugsee iOS SDK's
3671
+ # tools.bundle/BugseeAgent build phase already collected the
3672
+ # xcactivitylog-derived build timings for this build, the
3673
+ # fastlane Ruby action passes --no-timings here and the
3674
+ # build-info pipeline emits no `build_metadata.timings`
3675
+ # sub-object and skips the timings PUT.
3676
+ parser.add_option("--no-timings", dest="collect_timings",
3677
+ action="store_false", default=True,
3678
+ help="Skip the xcactivitylog-derived build timings "
3679
+ "collection + upload step. Symbols + deps "
3680
+ "uploads still run unless disabled separately.")
3681
+ # bugsee-cli override knobs. See the resolver block near the top of
3682
+ # this file for the full precedence rules.
3683
+ parser.add_option("--cli-path", dest="cli_path",
3684
+ default=os.environ.get("BUGSEE_CLI_PATH"),
3685
+ help="Path to a local bugsee-cli binary. When set and the file "
3686
+ "is executable, this binary is used for symbol upload "
3687
+ "instead of the auto-downloaded one. Lets developers test "
3688
+ "a locally-built CLI without touching the on-disk cache.")
3689
+ parser.add_option("--cli-version", dest="cli_version",
3690
+ default=os.environ.get("BUGSEE_CLI_VERSION"),
3691
+ help="bugsee-cli version to auto-download when --cli-path is "
3692
+ "unset. Defaults to BUGSEE_CLI_DEFAULT_VERSION (pinned to "
3693
+ "the version this agent was shipped against).")
3694
+ parser.add_option("--project-root", dest="project_root",
3695
+ default=os.environ.get('SRCROOT') or os.environ.get('PROJECT_DIR'),
3696
+ help="Root of the iOS project — where lockfiles "
3697
+ "(Podfile.lock / Package.resolved / Cartfile.resolved) "
3698
+ "are searched for. Defaults to $SRCROOT then "
3699
+ "$PROJECT_DIR when run from Xcode.")
3700
+ # ──────────────────────────────────────────────
3701
+ # Android mapping upload mode (off by default)
3702
+ # ──────────────────────────────────────────────
3703
+ # When --upload-mapping is passed, BugseeAgent skips ALL the
3704
+ # iOS-side flows (dSYM upload, dependencies pipeline,
3705
+ # daemonization, dSYM-folder scanning) and runs ONE step:
3706
+ # resolve bugsee-cli, upload the mapping.txt at --mapping-path
3707
+ # via `debug-files upload --type proguard --uuid <pre-resolved>`.
3708
+ # The fastlane plugin's upload_mapping_to_bugsee Ruby action
3709
+ # resolves the UUID upstream (from explicit override, the Bugsee
3710
+ # Gradle plugin's build-uuid.txt, or synthesis matching the
3711
+ # SDK's runtime fallback formula) and passes the pre-resolved
3712
+ # value via --mapping-uuid.
3713
+ parser.add_option("--upload-mapping", dest="upload_mapping",
3714
+ action="store_true", default=False,
3715
+ help="Run in Android mapping upload mode. Skips "
3716
+ "iOS dSYM / deps flows entirely. Requires "
3717
+ "--mapping-path, --mapping-uuid, --version, "
3718
+ "--build, and the positional app token.")
3719
+ parser.add_option("--mapping-path", dest="mapping_path",
3720
+ default=None,
3721
+ help="Path to the Android mapping.txt to upload. "
3722
+ "Only used with --upload-mapping.")
3723
+ parser.add_option("--mapping-uuid", dest="mapping_uuid",
3724
+ default=None,
3725
+ help="Pre-resolved BUILD_UUID for the mapping. "
3726
+ "The fastlane action computes this Ruby-side "
3727
+ "(from the Gradle plugin's build-uuid.txt or "
3728
+ "by synthesis matching the SDK's runtime "
3729
+ "fallback formula) so the SDK can match the "
3730
+ "uploaded mapping against runtime crashes.")
3731
+ parser.add_option("--icon", dest="icon_path",
3732
+ default=None,
3733
+ help="Optional launcher-icon PNG path. Forwarded "
3734
+ "to bugsee-cli's --icon flag for "
3735
+ "--type proguard uploads.")
3736
+ parser.add_option("--upload-artifact", dest="upload_artifact",
3737
+ action="store_true", default=False,
3738
+ help="iOS artifact-upload flow. Packages the .app "
3739
+ "at --app-path into a synthetic .ipa and "
3740
+ "uploads it to the back-end for size analysis. "
3741
+ "Short-circuits the dSYM / deps / timings "
3742
+ "pipeline. Used by the upload_artifact_to_bugsee "
3743
+ "fastlane action.")
3744
+ parser.add_option("--app-path", dest="app_path",
3745
+ default=None,
3746
+ help="Path to the built `.app` directory. Required "
3747
+ "by --upload-artifact. Typically resolved by "
3748
+ "the fastlane action from xcarchive_path + "
3749
+ "Products/Applications/<App>.app or from "
3750
+ "TARGET_BUILD_DIR/WRAPPER_NAME.")
3751
+ parser.add_option("--request-artifact-upload", dest="request_artifact_upload",
3752
+ action="store_true", default=False,
3753
+ help="Set the build registration payload's "
3754
+ "`request_artifact_upload` field to true. "
3755
+ "Implied by --upload-artifact; expose "
3756
+ "separately so a future build-info-only "
3757
+ "caller can request size analysis without "
3758
+ "actually shipping the artefact bytes.")
3759
+ parser.add_option("--build-info-only", dest="build_info_only",
3760
+ action="store_true", default=False,
3761
+ help="Inverse of --request-artifact-upload. With "
3762
+ "--upload-artifact, this flips the flow into "
3763
+ "register-only mode: the registration POST "
3764
+ "still records `artifact_size` on the server "
3765
+ "(so the dashboard's size-trend chart works) "
3766
+ "but the .ipa bytes never leave the build "
3767
+ "host. Useful for firewalled CI and privacy-"
3768
+ "sensitive setups.")
289
3769
  (options, args) = parser.parse_args()
290
3770
 
3771
+ # ──────────────────────────────────────────────
3772
+ # App-token check (BEFORE daemonization)
3773
+ # ──────────────────────────────────────────────
3774
+ # Pulled in front of the fork so a missing-token error reaches
3775
+ # the user's terminal / Xcode build log instead of being
3776
+ # swallowed by the daemonized child's redirected stdout.
3777
+ if (len(args) < 1):
3778
+ print("Bugsee: Not initialized with app token. Must be passed as a parameter")
3779
+ exit(1)
3780
+ APP_TOKEN = args[0]
3781
+
3782
+ # ──────────────────────────────────────────────
3783
+ # Android mapping upload — short-circuit path
3784
+ # ──────────────────────────────────────────────
3785
+ # Runs BEFORE the from_xcode daemonization fork so the
3786
+ # mapping upload is fully synchronous from the caller's
3787
+ # perspective (the Ruby action's Actions.sh shells to us and
3788
+ # blocks on completion). Skips:
3789
+ # - the iOS dSYM upload flow (--type dsym is wrong for Android)
3790
+ # - the iOS deps pipeline (Podfile.lock / Package.resolved /
3791
+ # Cartfile.resolved scanning — irrelevant for an Android
3792
+ # build's mapping.txt)
3793
+ # - the daemonization double-fork (the iOS-specific Xcode-
3794
+ # post-action runs out-of-process so it can't block the
3795
+ # build; the fastlane Android lane WANTS to block).
3796
+ # The fastlane action's Ruby side handles UUID resolution and
3797
+ # input validation; here we just exec the CLI.
3798
+ if options.upload_mapping:
3799
+ if not options.mapping_path:
3800
+ print("Bugsee: --upload-mapping requires --mapping-path")
3801
+ exit(1)
3802
+ if not options.mapping_uuid:
3803
+ print("Bugsee: --upload-mapping requires --mapping-uuid "
3804
+ "(fastlane-resolved BUILD_UUID for SDK matching)")
3805
+ exit(1)
3806
+ if not os.path.isfile(options.mapping_path):
3807
+ print("Bugsee: mapping file does not exist: %s"
3808
+ % options.mapping_path)
3809
+ exit(1)
3810
+ if options.icon_path and not os.path.isfile(options.icon_path):
3811
+ print("Bugsee: --icon path does not exist: %s "
3812
+ "(continuing without icon)" % options.icon_path)
3813
+ options.icon_path = None
3814
+ cliBinary = resolveCli(options.cli_path, options.cli_version)
3815
+ if not cliBinary:
3816
+ print("Bugsee: bugsee-cli unavailable; mapping upload skipped")
3817
+ exit(1)
3818
+ ok = uploadMappingViaCli(
3819
+ cliBinary, options.mapping_path, APP_TOKEN,
3820
+ options.endpoint, options.version, options.build,
3821
+ options.mapping_uuid, options.icon_path,
3822
+ )
3823
+ if ok:
3824
+ print("Bugsee: mapping uploaded successfully")
3825
+ exit(0)
3826
+ else:
3827
+ print("Bugsee: mapping upload failed")
3828
+ exit(1)
3829
+
3830
+ # ──────────────────────────────────────────────
3831
+ # iOS artefact upload — short-circuit path
3832
+ # ──────────────────────────────────────────────
3833
+ # Runs BEFORE the from_xcode daemonization fork so the artefact
3834
+ # upload is fully synchronous from the caller's perspective (the
3835
+ # Ruby action's Actions.sh blocks on completion). Skips:
3836
+ # - the iOS dSYM upload flow (orthogonal, has its own action)
3837
+ # - the deps/timings pipeline (those have their own
3838
+ # handshake-gated entry path)
3839
+ # - the daemonization double-fork (the fastlane lane wants to
3840
+ # block on the upload — the post-action daemon is an iOS
3841
+ # SDK BugseeAgent-only concern)
3842
+ if options.upload_artifact:
3843
+ if not options.app_path:
3844
+ print("Bugsee: --upload-artifact requires --app-path")
3845
+ exit(1)
3846
+ if not os.path.isdir(options.app_path):
3847
+ print("Bugsee: --app-path does not exist or is not a "
3848
+ "directory: %s" % options.app_path)
3849
+ exit(1)
3850
+ if not options.app_path.endswith('.app'):
3851
+ print("Bugsee: --app-path must end in .app: %s"
3852
+ % options.app_path)
3853
+ exit(1)
3854
+ # `--build-info-only` flips the flow into register-only
3855
+ # mode (records artifact_size, ships no bytes). The agent's
3856
+ # default is byte-shipping (request_artifact_upload=True).
3857
+ ok = run_artifact_upload_flow(
3858
+ APP_TOKEN, options.endpoint, options.app_path,
3859
+ version=options.version,
3860
+ build_number=options.build,
3861
+ request_artifact_upload=not options.build_info_only,
3862
+ )
3863
+ if ok:
3864
+ print("Bugsee: artefact uploaded successfully")
3865
+ exit(0)
3866
+ else:
3867
+ print("Bugsee: artefact upload failed")
3868
+ exit(1)
3869
+
291
3870
  if options.from_xcode:
292
3871
  # do the UNIX double-fork magic, see Stevens' "Advanced
293
3872
  # Programming in the UNIX Environment" for details (ISBN 0201563177)
294
- try:
295
- pid = os.fork()
3873
+ try:
3874
+ pid = os.fork()
296
3875
  if pid > 0:
297
3876
  # exit first parent
298
- sys.exit(0)
299
- except OSError as e:
300
- print("fork #1 failed: %d (%s)" % (e.errno, e.strerror), file=sys.stderr)
3877
+ sys.exit(0)
3878
+ except OSError as e:
3879
+ print("fork #1 failed: %d (%s)" % (e.errno, e.strerror), file=sys.stderr)
301
3880
  sys.exit(1)
302
3881
 
303
3882
  # decouple from parent environment
304
- os.chdir("/")
305
- os.setsid()
306
- os.umask(0)
3883
+ os.chdir("/")
3884
+ os.setsid()
3885
+ os.umask(0)
307
3886
 
308
3887
  # do second fork
309
- try:
310
- pid = os.fork()
3888
+ try:
3889
+ pid = os.fork()
311
3890
  if pid > 0:
312
3891
  # exit from second parent, print eventual PID before
313
- print("Daemon PID %d" % pid)
314
- sys.exit(0)
315
- except OSError as e:
316
- print("fork #2 failed: %d (%s)" % (e.errno, e.strerror), file=sys.stderr)
3892
+ print("Daemon PID %d" % pid)
3893
+ sys.exit(0)
3894
+ except OSError as e:
3895
+ print("fork #2 failed: %d (%s)" % (e.errno, e.strerror), file=sys.stderr)
317
3896
  sys.exit(1)
318
3897
 
319
3898
  # redirect standard file descriptors
@@ -329,6 +3908,36 @@ if __name__ == "__main__":
329
3908
  os.dup2(so.fileno(), sys.stdout.fileno())
330
3909
  os.dup2(se.fileno(), sys.stderr.fileno())
331
3910
 
3911
+ # ──────────────────────────────────────────────
3912
+ # Dependencies pipeline (runs in the daemonized child)
3913
+ # ──────────────────────────────────────────────
3914
+ #
3915
+ # Hoisted ABOVE the dSYM / simulator gates that follow because
3916
+ # the deps pipeline is independent of symbolication:
3917
+ #
3918
+ # - Debug builds with `DEBUG_INFORMATION_FORMAT != dwarf-with-dsym`
3919
+ # still ship their lockfile-derived dep list.
3920
+ # - Simulator builds skip symbols but still get deps.
3921
+ #
3922
+ # Errors are caught and logged so a deps failure (parser bug,
3923
+ # network blip, ngrok tunnel down) cannot prevent the symbols
3924
+ # pipeline from running afterward — the two are independent.
3925
+ if options.collect_deps:
3926
+ try:
3927
+ _run_dependencies_pipeline(APP_TOKEN, options.project_root)
3928
+ except Exception as e:
3929
+ print("Bugsee: dependencies pipeline failed: %s — "
3930
+ "continuing with symbols upload." % e)
3931
+
3932
+ # ──────────────────────────────────────────────
3933
+ # Symbols-pipeline gates
3934
+ # ──────────────────────────────────────────────
3935
+ # Each gate exits the (already-daemonized) process cleanly so
3936
+ # the user's build log doesn't spam errors about dSYMs that
3937
+ # aren't supposed to exist on Debug / simulator builds. Deps
3938
+ # already ran above — these gates only short-circuit the
3939
+ # symbols half.
3940
+ if options.from_xcode:
332
3941
  if os.environ.get('DEBUG_INFORMATION_FORMAT') != 'dwarf-with-dsym':
333
3942
  print("Bugsee: DEBUG_INFORMATION_FORMAT is not set. Have you enabled debug symbols in your build? See: https://docs.bugsee.com/sdk/ios/symbolication/")
334
3943
  exit(0)
@@ -337,13 +3946,6 @@ if __name__ == "__main__":
337
3946
  print("Bugsee: Bugsee is not supoorted in iOS simulator. Will not upload debug symbols for i386!")
338
3947
  exit(0)
339
3948
 
340
-
341
- if (len(args) < 1):
342
- print("Bugsee: Not initialized with app token. Must be passed as a parameter")
343
- exit(1)
344
-
345
- APP_TOKEN = args[0]
346
-
347
3949
  if not options.build_dir:
348
3950
  print('Target build directory was not specified. Either provide it with the "-d" option or set "TARGET_BUILD_DIR" environment variable')
349
3951
  exit(1)
@@ -358,5 +3960,5 @@ if __name__ == "__main__":
358
3960
  print("Bugsee: Can not find dSYM folder (expecting either a -f option or DWARF_DSYM_FOLDER_PATH)")
359
3961
  exit(1)
360
3962
 
361
- # start the daemon main loop
362
- main()
3963
+ # start the daemon main loop (symbols pipeline only)
3964
+ main()