Repository navigation
505 lines (475 loc) · 21.8 KB
/
Copy pathci.yml
File metadata and controls
505 lines (475 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Never a real token. The example reads credentials at runtime; CI only
# builds, so a well-formed placeholder is enough and nothing secret is
# needed to prove the native plumbing compiles and links.
BUGSEE_TOKEN_IOS: 00000000-0000-4000-8000-000000000000
BUGSEE_TOKEN_ANDROID: 00000000-0000-4000-8000-000000000000
# A dead loopback port, so nothing a job runs (tests, mutants, the build
# hooks' source-map upload) can reach the Bugsee API, whatever the token.
# ios-e2e sets its own endpoint in the one step that needs it.
BUGSEE_ENDPOINT: http://127.0.0.1:9
jobs:
js:
name: lint, typecheck, unit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- run: corepack enable
- run: yarn install --immutable
- run: yarn lint
- run: yarn typecheck
- run: yarn test
# Mutation gates. Each fails the build under its config's break threshold.
# The point is not the number: tests that assert nothing still pass, and
# this is what notices.
#
# One gate per stryker.<gate>.json, each a call to mutation-gate.yml with a
# shard count. That workflow cuts the gate into N jobs (scripts/
# cli-mutation.ts): Stryker places a mutant only when a `mutate` range holds
# all of it, so the cuts fall only between lines no mutant spans, and shards
# are balanced by mutant count. Each shard runs with `break` lifted and
# uploads its JSON report; the gate's `score` job merges them, checks the
# merged mutant count against the plan, and enforces `break` on the whole
# through mutation-testing-metrics, the calculation Stryker itself uses. The
# merged report is kept as the mutation-<gate> artifact.
#
# On a pull request a gate runs only when something it reads changed: its
# mutated files, the tests in its Jest `testMatch`, its own config, or
# anything every gate reads (package.json and its Jest config, yarn.lock,
# the tsconfigs, these workflows, the sharding scripts). A gate that does
# not run shows as skipped. Pushes to main and manual runs run every gate,
# which also catches what a path filter cannot see (a test helper outside
# testMatch changing what a test kills).
#
# Each config carries its own Jest config for the mutation run: roots and
# testMatch cut to the gate's tests (Stryker already runs only related
# tests per mutant; this shrinks Jest's module map and the dry run, 19 s to
# 2 s locally), and ts-jest through tsconfig.mutation.json, which sets
# isolatedModules, with diagnostics off: transpile only, since typechecking
# is the js job's. A local sample of 397 src mutants went 162 s -> 113 s
# with identical per-mutant results.
#
# scripts sets ignoreStatic: a static mutant (a module-level constant)
# reruns the whole suite, and in scripts/ those were 64% of the mutants and
# 99% of the run time. src keeps its static mutants: they are mostly
# killed, and without them it scores 92. The configs cap concurrency at 3
# for laptops; a runner has 4 vCPUs. Before: src 36-49 min, scripts 13-22
# min, one job each; plugin did not finish. At 3/2/10 shards: src 6-18,
# scripts 3-17, plugin 4-17 min a shard. Shards balance by mutant count,
# not cost (raw-messages.ts and index.ts mutants are dearer), hence 4/3.
#
# Tests that read files at runtime (the corpus a parser is scored against)
# list those paths in GATE_INPUTS in scripts/mutation-shards.ts.
#
# To add a gate: its stryker.<gate>.json (with jest.config.testMatch, or
# every change selects it), a job below, and that job in the `mutation`
# aggregator's `needs`. To reshard, change `shards`.
mutation-scope:
name: mutation scope
runs-on: ubuntu-latest
outputs:
gates: ${{ steps.scope.outputs.gates }}
steps:
- uses: actions/checkout@v7
with:
# A pull request checks out its merge commit; its first parent is
# the base branch, so HEAD^1..HEAD is exactly what the PR changes.
fetch-depth: 2
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- id: scope
run: |
if [ "${{ github.event_name }}" = pull_request ]; then
git diff --name-only HEAD^1 HEAD | tee /dev/stderr | node scripts/cli-mutation.ts scope >> "$GITHUB_OUTPUT"
else
node scripts/cli-mutation.ts scope --all >> "$GITHUB_OUTPUT"
fi
cat "$GITHUB_OUTPUT"
mutation-src:
name: mutation (src)
needs: mutation-scope
if: contains(fromJSON(needs.mutation-scope.outputs.gates), 'src')
uses: ./.github/workflows/mutation-gate.yml
with:
gate: src
shards: 4
mutation-scripts:
name: mutation (scripts)
needs: mutation-scope
if: contains(fromJSON(needs.mutation-scope.outputs.gates), 'scripts')
uses: ./.github/workflows/mutation-gate.yml
with:
gate: scripts
shards: 3
# The plugin gate (the Expo config plugin and the Hermes source-map
# scripts, which decide whether a debug id, a dSYM action or a source-map
# upload reaches a customer build) runs inPlace: its tests need the
# workspace's nested node_modules, which a sandbox never copies.
# Unsharded it did not finish: about 10% after 70 min. Its Gradle lexer is
# one function whose BlockStatement mutant spans 419 others; in 7
# line-range shards that one ran past 80 min while the other six took 4-13.
# Such a group is now split by mutator into shards of its own
# (scripts/mutation-shards.ts), here 5 of the 10. timeoutMS is 15 s, not
# 60 (a Stryker timeout is 1.5x the covering tests' own time plus this):
# lexer mutants that loop forever each wait it out.
mutation-plugin:
name: mutation (plugin)
needs: mutation-scope
if: contains(fromJSON(needs.mutation-scope.outputs.gates), 'plugin')
uses: ./.github/workflows/mutation-gate.yml
with:
gate: plugin
shards: 10
# The one stable check name for branch protection: a gate's checks are
# nested (mutation (src) / score) and a filtered-out gate is skipped. Fails
# if the scope or any gate failed or was cancelled; skipped gates pass. A new
# gate's job goes into `needs`.
mutation:
name: mutation
needs: [mutation-scope, mutation-src, mutation-scripts, mutation-plugin]
if: always()
runs-on: ubuntu-latest
steps:
- env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "results: $RESULTS"
for result in $RESULTS; do
case "$result" in
success|skipped) ;;
*) echo "::error::a mutation gate ended $result"; exit 1 ;;
esac
done
expo-prebuild:
name: expo prebuild (config plugin)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- run: corepack enable
- run: yarn install --immutable
# Spec §13: the plugin's edits survive `expo prebuild --clean`, and a
# `--no-clean` prebuild over its own output changes nothing. No pods,
# no build yet.
- run: ./scripts/check-expo-prebuild.sh
compat:
name: rn compat (${{ matrix.react-native }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
react-native: ['0.81', '0.82', '0.83', '0.84', '0.85', '0.86', '0.87']
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.18'
# Codegen and typecheck against each supported React Native, which is
# the surface that is actually ours. The example app cannot stand in for
# this: its android/ and ios/ projects come from one version's template,
# and swapping the npm dependency does not swap the project.
- run: ./scripts/check-rn-compat.sh ${{ matrix.react-native }}
# The packed tarballs installed into a fresh `react-native init` app, as a
# customer gets them from npm (scripts/check-pack-install.sh). Inside the
# workspace every lookup finds the repo root and the repo's own tsconfig,
# so none of the jobs above can see what this does: native-versions.json
# missing from the tarball broke Gradle and pod install for every npm
# install (BLK-17), and the app's own `tsc` failed on 0.87 (API-49).
# Typecheck on every supported minor; Gradle configuration on the floor
# and the latest.
pack-install:
name: pack install (${{ matrix.react-native }}${{ matrix.android && ', android' || '' }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
react-native: ['0.81', '0.82', '0.83', '0.84', '0.85', '0.86', '0.87']
include:
- react-native: '0.81'
android: true
- react-native: '0.87'
android: true
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- uses: actions/setup-java@v5
if: matrix.android
with:
distribution: temurin
java-version: 17
- run: ./scripts/check-pack-install.sh ${{ matrix.react-native }} --typecheck ${{ matrix.android && '--android' || '' }}
# The iOS half: `pod install` evaluates both podspecs from the tarballs and
# vendors the SDK (BLK-17).
pack-install-ios:
name: pack install (${{ matrix.react-native }}, ios)
runs-on: macos-15
strategy:
fail-fast: false
matrix:
react-native: ['0.81', '0.87']
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- run: ./scripts/check-pack-install.sh ${{ matrix.react-native }} --ios
android:
name: android
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Fails fast, before spending a single minute on setup or a Gradle
# download: native-versions.json can pin a SNAPSHOT built from a
# private clone (Task 3.P1), which only exists in one developer's
# ~/.m2 and which CI has no way to reproduce. Left unchecked this
# surfaces many minutes later as an opaque dependency-resolution
# failure instead of a named reason. This step stays in place after
# Task 3.P3 flips the pin back to a release, where it simply passes.
- name: Refuse a local SNAPSHOT pin
run: |
sdk=$(jq -r '.android.sdk' native-versions.json)
if [[ "$sdk" == *-SNAPSHOT ]]; then
echo "::error title=Local SNAPSHOT pin::native-versions.json pins Android ${sdk}, built from a private clone; CI cannot resolve it. Expected red until the pin flips to the released version."
exit 1
fi
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- run: corepack enable
- run: yarn install --immutable
- run: yarn workspace bugsee-example-bare credentials
# The library's own unit tests, from the root build.
- run: ./gradlew :bugsee-android-bridge:testDebugUnitTest :bugsee-android-feedback:testDebugUnitTest
# The example carries its own wrapper and settings; the root build knows
# nothing about it, so it is built from its own directory. The
# example-only bugsee-e2e-native module (Task 7.6a) is only reachable
# through the example's autolinking, so its JVM tests run here too.
- name: Assemble the example
working-directory: examples/bare/android
run: ./gradlew assembleDebug :bugsee-e2e-native:testDebugUnitTest
# The source-map hook in a real Gradle build with stand-in AGP and React
# Native pieces. Off in the js job, which has no JDK set up.
- name: Test the source-map Gradle hook
run: BUGSEE_GRADLE_TESTS=1 yarn jest packages/react-native/scripts/__tests__/sourcemaps-gradle.test.js
ios-unit:
name: ios unit (BugseeRNSupport)
runs-on: macos-15
steps:
- uses: actions/checkout@v7
# The only ObjC tests in the repo, and they used to run nowhere. They
# cover the marshalling helpers and the main-thread hop -- removing the
# hop is otherwise undetectable, since the device e2e passes without it.
#
# xcodebuild, not `swift test`: SwiftPM builds this for macOS and the
# xcframework has no macOS slice.
# Selected by UDID rather than by name+OS: `OS=latest` resolves to the
# newest runtime installed, which need not have the named device, and
# xcodebuild then fails listing every destination it will not use.
#
# Filtered to an iPhone on an iOS runtime. A GitHub Xcode image installs
# watchOS, tvOS and visionOS runtimes too, and simctl returns them in the
# same map -- locally 65 of the 100 entries are non-iOS, the first at
# index 22. Taking [0] of the flattened list trusts JSON key ordering to
# put iOS first, which nothing guarantees.
- name: Test the Support package
working-directory: packages/react-native/ios/Support
run: |
UDID="$(xcrun simctl list devices available -j | jq -r '[.devices | to_entries[] | select(.key | test("SimRuntime\\.iOS-")) | .value[] | select(.name | startswith("iPhone"))][0].udid')"
test -n "$UDID" && test "$UDID" != null
xcodebuild test -scheme BugseeRNSupport -destination "id=$UDID"
ios-e2e:
name: ios e2e (simulator)
runs-on: macos-15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- run: corepack enable
- run: yarn install --immutable
# No secret in this job. The placeholder token reaches Launched: the
# app forces the dead endpoint for it (examples/bare/endpoint.ts), and
# the SDK launches offline against it, which is all launch.test.ts
# asserts. Verified on a fresh simulator: warm-up, then launch.test.ts,
# with the all-zero token. (The old claim that a placeholder "fails to
# bring capture up" held for the real endpoint, where the server
# rejects it and the SDK stops.) The endpoint is the literal dead one,
# never a secret, so no fallback path can contact a real server.
- name: Credentials
env:
BUGSEE_TOKEN_IOS: 00000000-0000-4000-8000-000000000000
BUGSEE_ENDPOINT: https://127.0.0.1:9
run: yarn workspace bugsee-example-bare credentials
- name: Boot a simulator
id: sim
run: |
UDID="$(xcrun simctl list devices available -j | jq -r '[.devices | to_entries[] | select(.key | test("SimRuntime\\.iOS-")) | .value[] | select(.name | startswith("iPhone"))][0].udid')"
test -n "$UDID" && test "$UDID" != null
# Erased before boot, deliberately. A runner's simulator is not
# guaranteed clean: a previous run saw the SDK fail its keychain
# write with errSecMissingEntitlement (-34018) and stall at
# Launching, on the same device that reached Launched in 2.6s the
# run before. Locally, leftover simulator state made two runs of
# this same test disagree. Erase, then wait for a full boot.
xcrun simctl shutdown "$UDID" || true
xcrun simctl erase "$UDID"
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID" -b
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
- name: Install pods
working-directory: examples/bare/ios
run: pod install
- name: Build for the simulator
working-directory: examples/bare/ios
run: |
# CODE_SIGNING_ALLOWED=NO leaves a linker-signed slice the simulator
# keychain rejects with errSecMissingEntitlement (-34018) on
# bugseeDeviceId; capture then never reaches Launched. Ad-hoc sign
# instead: no Apple ID, but enough for SecItemAdd on the simulator.
xcodebuild \
-workspace BareExample.xcworkspace \
-scheme BareExample \
-destination "id=${{ steps.sim.outputs.udid }}" \
-configuration Debug \
-derivedDataPath build \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY=- \
CODE_SIGNING_REQUIRED=NO \
build
APP=build/Build/Products/Debug-iphonesimulator/BareExample.app
if [ -d "$APP/Frameworks" ]; then
find "$APP/Frameworks" -depth \( -name "*.framework" -o -name "*.dylib" \) -print0 \
| while IFS= read -r -d '' item; do
codesign --force --sign - --timestamp=none "$item"
done
fi
codesign --force --sign - --timestamp=none "$APP"
- name: Install the app
run: |
xcrun simctl install "${{ steps.sim.outputs.udid }}" \
examples/bare/ios/build/Build/Products/Debug-iphonesimulator/BareExample.app
# Bugsee.launch() compiles the SDK's Metal capture shaders on the main
# thread. On this freshly erased simulator the compiler is cold: about
# 2 s, and now and then more than the 10 s the e2e gives Status.Launched
# (a sample of one such run: main inside InitMetalDevice ->
# newLibraryWithSource:, waiting on MTLCompilerService). The status poll
# reads on main, so the console goes quiet after `status=1`. One launch
# first leaves the compiler warm, and the e2e times the SDK. It serves
# that launch from a Metro of its own: the e2e's Metro must not build a
# bundle before the e2e writes its scenario file.
#
# Follow-up, not done here: prime Metro's transform cache while
# xcodebuild compiles (curl the warm-up Metro's bundle in the
# background before "Install pods"), to recover most of the warm-up's
# 40 to 85 s.
- name: Warm the simulator's Metal compiler
working-directory: examples/bare
run: ./scripts/warm-simulator-metal.sh "${{ steps.sim.outputs.udid }}"
- name: Start Metro
working-directory: examples/bare
run: |
yarn start &
for _ in $(seq 1 60); do
curl -sf http://localhost:8081/status && break
sleep 1
done
# The only job that proves the SDK RUNS. Every other job passes with
# `launch` gutted to `resolve(@YES)`: the build succeeds and the
# framework is still embedded and linked. Verified by doing exactly
# that -- the embed assertion stayed green and this went red.
#
# A simulator is not a substitute for the handset: it cannot catch a
# code-signing or embedding fault on real hardware. It is the only iOS
# target CI has, and it runs the real SDK.
#
# launch.test.ts only, by ruling. A bare `yarn e2e` also picks up the
# report-handler and wrapper-channel suites, whose timing windows (a
# 25 s deadline asserted to within a second) a shared runner cannot
# promise, and which retain bundles -- on iOS beta3 those carry
# credentials, and CI has no policy for keeping them. Jest reads the
# argument as a regex over the path: anchored, so a file whose name
# merely contains "launch.test.ts" (api-relaunch.test.ts) stays out.
- name: Reach Status.Launched
working-directory: examples/bare
env:
E2E_PLATFORM: ios
E2E_IOS_TARGET: simulator
IOS_SIMULATOR_ID: ${{ steps.sim.outputs.udid }}
run: yarn e2e '/launch\.test\.ts$'
# The e2e reads only the console pty. The app's own persisted log says
# whether a missing marker was never logged or logged and not streamed.
- name: App log on failure
if: failure()
run: |
xcrun simctl spawn "${{ steps.sim.outputs.udid }}" log show --last 15m --style compact \
--predicate 'process == "BareExample"' \
| grep -E 'BUGSEE_E2E|Bugsee|BugseeRN|Metal' | cut -c1-300 | tail -300 || true
ios:
name: ios (${{ matrix.delivery }})
runs-on: macos-15
strategy:
fail-fast: false
matrix:
# Both delivery paths, on the example's own React Native. SPM support
# for apps does not exist before 0.87 anyway — 0.81-0.86 ship only
# scripts/cocoapods/spm.rb, the spm_dependency helper the spike
# disproved — so there is no older SPM leg to run.
delivery: [cocoapods, spm]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.18'
- run: corepack enable
- run: yarn install --immutable
- run: yarn workspace bugsee-example-bare credentials
- name: Install pods
if: matrix.delivery == 'cocoapods'
working-directory: examples/bare/ios
run: pod install
- name: Migrate to SPM
if: matrix.delivery == 'spm'
working-directory: examples/bare
run: npx react-native spm add --deintegrate --yes
- name: Build
working-directory: examples/bare/ios
run: |
xcodebuild \
-workspace BareExample.xcworkspace \
-scheme BareExample \
-destination 'generic/platform=iOS' \
-configuration Debug \
-derivedDataPath build \
CODE_SIGNING_ALLOWED=NO \
build
# The whole point of this job. The spm_dependency variant built cleanly
# and then dyld-crashed on the device, so a green build proves nothing
# on its own — only that the framework is embedded AND linked does.
#
# Through the example's OWN script rather than the root one with an
# explicit path, so CI exercises the invocation a developer actually
# runs. When the two differed, a broken working directory in the local
# path stayed green here for two pushes.
- name: Assert Bugsee.framework is embedded and linked
run: yarn workspace bugsee-example-bare assert:ios-embed