fastlane-plugin-bugsee 1.0.4 → 1.1.0

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