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