Skip to content

Commit 56e8d03

Browse files
Soft-launch E2E PR checks
Assisted-By: devx/6c1e3ad5-96c8-4972-b087-da7ff7b195c3
1 parent 6dde7f4 commit 56e8d03

8 files changed

Lines changed: 361 additions & 9 deletions

e2e/BITRISE.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ The non-secret E2E defaults are defined in `e2e/bitrise.yml` under `app.envs`. D
6868
| `E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS` | `300` | Per-command timeout for package-suite matrix and suite commands. |
6969
| `E2E_REPORT_COMMAND_TIMEOUT_SECONDS` | `300` | Timeout for the GitHub reporting command. |
7070
| `E2E_RUBY_INSTALL_TIMEOUT_SECONDS` | `1800` | Timeout for installing the exact repository Ruby version from `.ruby-version`. |
71+
| `E2E_CANCEL_CONCURRENT_PR_BUILDS` | `true` | Cancels older in-progress E2E builds for the same PR when the Bitrise API token secret is available. |
7172

7273
Secrets still need to be configured in Bitrise.io.
7374

@@ -85,6 +86,14 @@ BrowserStack credentials are required by the `e2e-run-browserstack` workflow.
8586
| `BROWSERSTACK_USERNAME` | BrowserStack API username |
8687
| `BROWSERSTACK_ACCESS_KEY` | BrowserStack API access key |
8788

89+
BrowserStack artifact links in GitHub reports require access to BrowserStack App Automate. Sign in to [BrowserStack App Automate](https://app-automate.browserstack.com/dashboard/v2/builds) before opening build, video, screenshot, or log links.
90+
91+
## Bitrise API token
92+
93+
The package-suite workflow runs a PR concurrency guard before expensive E2E work. Configure `BITRISE_API_TOKEN` as a Bitrise secret so the guard can cancel older in-progress E2E builds for the same PR. If `BITRISE_API_TOKEN` is unavailable, the guard also accepts `BITRISE_PAT`.
94+
95+
If neither token is configured, the guard logs a skip message and the pipeline continues without cancellation.
96+
8897
## GitHub reporting
8998

9099
The report workflow uses a GitHub API token to create commit statuses, Check Runs, and sticky PR comments. Prefer the short-lived token generated by the Bitrise GitHub App over a long-lived personal access token.
@@ -97,7 +106,7 @@ Green runs update statuses and Check Runs without creating new PR comments. Fail
97106

98107
## PR trigger
99108

100-
Configure the Bitrise app to run the `e2e` pipeline for pull requests once the skeleton is ready to validate in Bitrise.
109+
`bitrise.yml` maps pull requests to the `e2e` pipeline with `trigger_map`. Keep the GitHub checks non-required while `E2E_STRICT=false`.
101110

102111
## Code signing
103112

e2e/RUNBOOK.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Checkout Kit E2E Runbook
2+
3+
## Rollout mode
4+
5+
The Bitrise E2E pipeline starts in soft-fail mode:
6+
7+
```bash
8+
E2E_STRICT=false
9+
```
10+
11+
In soft-fail mode, BrowserStack Maestro assertion failures are reported through GitHub commit statuses, Check Runs, and the sticky PR failure comment, but the BrowserStack workflow exits successfully so the initial rollout does not block PR progress.
12+
13+
Set `E2E_STRICT=true` only after the suite has stabilized and the corresponding GitHub contexts are ready to become required checks.
14+
15+
## Retry behavior
16+
17+
BrowserStack API calls retry transient infrastructure responses once by default:
18+
19+
```bash
20+
E2E_BROWSERSTACK_API_RETRIES=1
21+
```
22+
23+
Retry applies to HTTP 429 and 5xx responses. Maestro assertion failures are not auto-retried by default so first-failure evidence is preserved.
24+
25+
## Timeouts
26+
27+
BrowserStack polling uses these defaults:
28+
29+
```bash
30+
E2E_BROWSERSTACK_TIMEOUT_SECONDS=1800
31+
E2E_BROWSERSTACK_POLL_SECONDS=30
32+
```
33+
34+
If polling times out, the runner attempts to stop the BrowserStack build before failing or soft-failing according to `E2E_STRICT`.
35+
36+
## Local rerun notes
37+
38+
Use the matrix row from a failure report to identify the app target, platform, OS track, and suite:
39+
40+
```bash
41+
ruby e2e/scripts/e2e_matrix expand --index <index>
42+
```
43+
44+
Use the reported resolved device to pin a rerun:
45+
46+
```bash
47+
E2E_DEVICE_OVERRIDE="<resolved BrowserStack device>" \
48+
E2E_STRICT=true \
49+
e2e/scripts/browserstack_maestro --index <index> --suite-zip <suite.zip> --output-dir <results-dir>
50+
```
51+
52+
The app artifact environment variable for the matrix row must point at the `.apk` or `.ipa` artifact before rerunning.
53+
54+
## Failure triage
55+
56+
Use the GitHub Check Run or sticky PR failure comment first. Failure summaries should include Markdown links to:
57+
58+
- BrowserStack build
59+
- failed testcase
60+
- video
61+
- screenshot
62+
- Maestro command log
63+
- Maestro log
64+
- device log
65+
- network log when enabled
66+
67+
BrowserStack artifact links require BrowserStack App Automate access. Sign in to [BrowserStack App Automate](https://app-automate.browserstack.com/dashboard/v2/builds) before opening evidence links.
68+
69+
Avoid posting additional PR comments for green runs.

e2e/bitrise.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ app:
2424
- E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS: "300"
2525
- E2E_REPORT_COMMAND_TIMEOUT_SECONDS: "300"
2626
- E2E_RUBY_INSTALL_TIMEOUT_SECONDS: "1800"
27+
- E2E_CANCEL_CONCURRENT_PR_BUILDS: "true"
28+
29+
trigger_map:
30+
- pull_request_source_branch: "*"
31+
pipeline: e2e
2732

2833
pipelines:
2934
e2e:
@@ -57,6 +62,8 @@ workflows:
5762
set -euo pipefail
5863
source e2e/scripts/bitrise_ci_helpers
5964
e2e_prepare_ruby
65+
e2e_log "Cancelling older in-progress E2E PR builds"
66+
ruby e2e/scripts/cancel_superseded_bitrise_pr_builds
6067
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix validate
6168
e2e_log "Expanding E2E matrix"
6269
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix expand > "$BITRISE_DEPLOY_DIR/e2e-matrix.json"
@@ -288,7 +295,7 @@ workflows:
288295
: "${E2E_REACT_NATIVE_IOS_APP_PATH:?E2E_REACT_NATIVE_IOS_APP_PATH is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
289296
: "${E2E_REACT_NATIVE_ANDROID_APP_PATH:?E2E_REACT_NATIVE_ANDROID_APP_PATH is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
290297
e2e_log "Running BrowserStack Maestro for matrix row ${run_index}"
291-
e2e/scripts/browserstack_maestro \
298+
E2E_STRICT="${E2E_STRICT:-false}" e2e/scripts/browserstack_maestro \
292299
--index "$run_index" \
293300
--suite-zip "$E2E_MAESTRO_SUITE_ZIP" \
294301
--output-dir "$results_dir"
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
require "net/http"
5+
6+
class BitriseConcurrencyGuard
7+
def initialize(client:, app_slug:, current_build_slug:, current_build_number:, pr_number:, workflow_prefix:)
8+
@client = client
9+
@app_slug = app_slug
10+
@current_build_slug = current_build_slug
11+
@current_build_number = Integer(current_build_number)
12+
@pr_number = pr_number.to_s
13+
@workflow_prefix = workflow_prefix
14+
end
15+
16+
def cancel_older_pr_builds
17+
return [] if @pr_number.empty?
18+
19+
older_builds.map do |build|
20+
build_slug = build.fetch("slug")
21+
@client.abort_build(@app_slug, build_slug)
22+
build_slug
23+
end
24+
end
25+
26+
private
27+
28+
def older_builds
29+
@client.builds(@app_slug).select do |build|
30+
build.fetch("slug") != @current_build_slug &&
31+
build.fetch("pull_request_id").to_s == @pr_number &&
32+
build.fetch("status_text") == "in-progress" &&
33+
build.fetch("triggered_workflow").to_s.start_with?(@workflow_prefix) &&
34+
Integer(build.fetch("build_number")) < @current_build_number
35+
end
36+
end
37+
end
38+
39+
class BitriseApiClient
40+
API_HOST = "api.bitrise.io"
41+
42+
def initialize(token:)
43+
@token = token
44+
end
45+
46+
def builds(app_slug)
47+
get_json("/v0.1/apps/#{app_slug}/builds?limit=50").fetch("data")
48+
end
49+
50+
def abort_build(app_slug, build_slug)
51+
request = Net::HTTP::Post.new("/v0.1/apps/#{app_slug}/builds/#{build_slug}/abort")
52+
request.body = JSON.generate(
53+
abort_reason: "Superseded by a newer Checkout Kit E2E PR build",
54+
abort_with_success: false,
55+
skip_notifications: true
56+
)
57+
execute_json_request(request)
58+
end
59+
60+
private
61+
62+
def get_json(path)
63+
execute_request(Net::HTTP::Get.new(path))
64+
end
65+
66+
def execute_json_request(request)
67+
request["Content-Type"] = "application/json"
68+
execute_request(request)
69+
end
70+
71+
def execute_request(request)
72+
request["Authorization"] = @token
73+
response = Net::HTTP.start(API_HOST, 443, use_ssl: true) { |http| http.request(request) }
74+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
75+
return body if response.is_a?(Net::HTTPSuccess)
76+
77+
raise "Bitrise request failed #{response.code}: #{body}"
78+
end
79+
end

e2e/scripts/browserstack_maestro

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,15 @@ class BrowserStackMaestro
3333
sessions = fetch_sessions(build_status)
3434
result = normalize_result(run, device, app, suite, build, build_status, sessions)
3535
write_json("result.json", result)
36-
exit(result.fetch("passed") ? 0 : 1)
36+
exit(result.fetch("passed") || !strict? ? 0 : 1)
3737
end
3838

3939
private
4040

41+
def strict?
42+
ENV.fetch("E2E_STRICT", "false") == "true"
43+
end
44+
4145
def resolve_device(run)
4246
override = ENV["E2E_DEVICE_OVERRIDE"] || ENV["E2E_#{run.fetch("platform").upcase}_DEVICE_OVERRIDE"]
4347
if override && !override.empty?
@@ -85,12 +89,21 @@ class BrowserStackMaestro
8589
response = get_json("/app-automate/maestro/v2/builds/#{build_id}")
8690
write_json("build-status.json", response)
8791
return response if TERMINAL_STATUSES.include?(response.fetch("status").to_s.downcase)
88-
raise "BrowserStack build timed out: #{build_id}" if Time.now >= deadline
92+
if Time.now >= deadline
93+
stop_build(build_id)
94+
raise "BrowserStack build timed out: #{build_id}"
95+
end
8996

9097
sleep ENV.fetch("E2E_BROWSERSTACK_POLL_SECONDS", "30").to_i
9198
end
9299
end
93100

101+
def stop_build(build_id)
102+
post_json("/app-automate/maestro/builds/#{build_id}/stop", {})
103+
rescue StandardError => error
104+
warn "Unable to stop BrowserStack build #{build_id}: #{error.message}"
105+
end
106+
94107
def fetch_sessions(build_status)
95108
build_id = build_status.fetch("id")
96109
build_status.fetch("devices", []).flat_map do |device|
@@ -155,12 +168,28 @@ class BrowserStackMaestro
155168
end
156169

157170
def execute_request(request)
158-
request.basic_auth(@username, @access_key)
159-
response = Net::HTTP.start(API_HOST, 443, use_ssl: true) { |http| http.request(request) }
160-
parsed = JSON.parse(response.body)
161-
return parsed if response.is_a?(Net::HTTPSuccess)
171+
attempts = 0
172+
retries = ENV.fetch("E2E_BROWSERSTACK_API_RETRIES", "1").to_i
173+
loop do
174+
attempts += 1
175+
request.basic_auth(@username, @access_key)
176+
response = Net::HTTP.start(API_HOST, 443, use_ssl: true) { |http| http.request(request) }
177+
parsed = parse_response_body(response)
178+
return parsed if response.is_a?(Net::HTTPSuccess)
179+
raise "BrowserStack request failed #{response.code}: #{parsed}" unless retryable_browserstack_response?(response) && attempts <= retries
180+
181+
sleep attempts
182+
end
183+
end
184+
185+
def parse_response_body(response)
186+
JSON.parse(response.body)
187+
rescue JSON::ParserError
188+
raise "BrowserStack request returned non-JSON response"
189+
end
162190

163-
raise "BrowserStack request failed #{response.code}: #{parsed}"
191+
def retryable_browserstack_response?(response)
192+
response.code.to_i == 429 || response.code.to_i >= 500
164193
end
165194

166195
def custom_id(run, suffix)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require_relative "../lib/bitrise_concurrency_guard"
5+
6+
token = ENV["BITRISE_API_TOKEN"] || ENV["BITRISE_PAT"]
7+
if token.nil? || token.empty?
8+
warn "Skipping Bitrise PR concurrency guard because BITRISE_API_TOKEN is not set."
9+
exit 0
10+
end
11+
12+
if ENV.fetch("E2E_CANCEL_CONCURRENT_PR_BUILDS", "true") != "true"
13+
warn "Skipping Bitrise PR concurrency guard because E2E_CANCEL_CONCURRENT_PR_BUILDS is not true."
14+
exit 0
15+
end
16+
17+
guard = BitriseConcurrencyGuard.new(
18+
client: BitriseApiClient.new(token: token),
19+
app_slug: ENV.fetch("BITRISE_APP_SLUG"),
20+
current_build_slug: ENV.fetch("BITRISE_BUILD_SLUG"),
21+
current_build_number: ENV.fetch("BITRISE_BUILD_NUMBER"),
22+
pr_number: ENV.fetch("BITRISE_PULL_REQUEST", ""),
23+
workflow_prefix: ENV.fetch("E2E_BITRISE_WORKFLOW_PREFIX", "e2e")
24+
)
25+
26+
aborted = guard.cancel_older_pr_builds
27+
if aborted.empty?
28+
puts "No older in-progress E2E PR builds to cancel."
29+
else
30+
puts "Cancelled #{aborted.length} older in-progress E2E PR build(s)."
31+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
require "minitest/autorun"
4+
require_relative "../lib/bitrise_concurrency_guard"
5+
6+
class BitriseConcurrencyGuardTest < Minitest::Test
7+
def test_aborts_older_in_progress_pr_builds
8+
client = FakeBitriseClient.new([
9+
build("current", 42, "in-progress"),
10+
build("older-package", 41, "in-progress"),
11+
build("older-android", 41, "in-progress", workflow: "e2e-build-react-native-android"),
12+
build("newer", 43, "in-progress"),
13+
build("finished", 40, "success"),
14+
build("other-pr", 39, "in-progress", pr: "365"),
15+
])
16+
guard = BitriseConcurrencyGuard.new(
17+
client: client,
18+
app_slug: "app-slug",
19+
current_build_slug: "current",
20+
current_build_number: 42,
21+
pr_number: "364",
22+
workflow_prefix: "e2e"
23+
)
24+
25+
aborted = guard.cancel_older_pr_builds
26+
27+
assert_equal ["older-package", "older-android"], aborted
28+
assert_equal ["older-package", "older-android"], client.aborted_build_slugs
29+
end
30+
31+
def test_skips_without_pr_number
32+
client = FakeBitriseClient.new([build("older", 1, "in-progress")])
33+
guard = BitriseConcurrencyGuard.new(
34+
client: client,
35+
app_slug: "app-slug",
36+
current_build_slug: "current",
37+
current_build_number: 2,
38+
pr_number: "",
39+
workflow_prefix: "e2e"
40+
)
41+
42+
assert_empty guard.cancel_older_pr_builds
43+
assert_empty client.aborted_build_slugs
44+
end
45+
46+
private
47+
48+
def build(slug, number, status, pr: "364", workflow: "e2e-package-suite")
49+
{
50+
"slug" => slug,
51+
"build_number" => number,
52+
"status_text" => status,
53+
"pull_request_id" => pr,
54+
"triggered_workflow" => workflow,
55+
}
56+
end
57+
58+
class FakeBitriseClient
59+
attr_reader :aborted_build_slugs
60+
61+
def initialize(builds)
62+
@builds = builds
63+
@aborted_build_slugs = []
64+
end
65+
66+
def builds(_app_slug)
67+
@builds
68+
end
69+
70+
def abort_build(_app_slug, build_slug)
71+
@aborted_build_slugs << build_slug
72+
end
73+
end
74+
end

0 commit comments

Comments
 (0)