From 34d7d06999ab7273ea76a3d3866852a73edd2bd5 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 3 Jul 2026 10:53:32 +0100 Subject: [PATCH 1/4] Add Tophat integration for Checkout Kit React Native builds Assisted-By: devx/6c1e3ad5-96c8-4972-b087-da7ff7b195c3 --- dev.yml | 4 ++ e2e/lib/e2e_github_reporter.rb | 65 +++++++++++++------ e2e/scripts/report_e2e_results | 3 + scripts/configure_tophat_quick_launch.sh | 15 +++++ .../tophat/tophat_checkout_kit_android.json | 17 +++++ scripts/tophat/tophat_checkout_kit_ios.json | 30 +++++++++ 6 files changed, 114 insertions(+), 20 deletions(-) create mode 100755 scripts/configure_tophat_quick_launch.sh create mode 100644 scripts/tophat/tophat_checkout_kit_android.json create mode 100644 scripts/tophat/tophat_checkout_kit_ios.json diff --git a/dev.yml b/dev.yml index 4eb921f7..ed06a836 100644 --- a/dev.yml +++ b/dev.yml @@ -32,6 +32,10 @@ up: name: Run Checkout Kit workspace setup met?: ./scripts/setup_dev_workspace --check --skip-optional-prompts meet: ./scripts/setup_dev_workspace --skip-optional-prompts + - tophat_mobile + - run_always: + name: Configure Tophat Quick Launch items + run: sh scripts/configure_tophat_quick_launch.sh open: "GitHub": "https://github.com/Shopify/checkout-kit" diff --git a/e2e/lib/e2e_github_reporter.rb b/e2e/lib/e2e_github_reporter.rb index ac3da27c..fc643f3d 100644 --- a/e2e/lib/e2e_github_reporter.rb +++ b/e2e/lib/e2e_github_reporter.rb @@ -1,18 +1,27 @@ # frozen_string_literal: true require "json" +require "uri" require_relative "json_http_client" # Publishes normalized E2E run results back to GitHub as commit statuses, # check runs, and a sticky pull request comment for failures. class E2EGitHubReporter COMMENT_MARKER = "" - - def initialize(results, repository:, sha:, pr_number:, token: nil, strict: false) + TOPHAT_APP_SLUG = "f51f9054-053e-40f1-81e9-ae727567ae76" + TOPHAT_INSTALL_BASE = "http://localhost:29070/install/bitrise-branch" + TOPHAT_RECIPES = [ + {platform: "ios", destination: "device", workflow: "e2e-build-react-native-ios", artifact_name: "CheckoutKitReactNativeDemo-Provisioned.ipa"}, + {platform: "ios", destination: "simulator", workflow: "e2e-build-react-native-ios", artifact_name: "CheckoutKitReactNativeDemo-Simulator.zip"}, + {platform: "android", destination: "any", workflow: "e2e-build-react-native-android", artifact_name: "app-e2e.apk"} + ].freeze + + def initialize(results, repository:, sha:, pr_number:, branch: nil, token: nil, strict: false) @results = results @repository = repository @sha = sha @pr_number = pr_number + @branch = branch @token = token @strict = strict end @@ -20,7 +29,25 @@ def initialize(results, repository:, sha:, pr_number:, token: nil, strict: false def publish! commit_status_payloads.each { |payload| client.post_json("/repos/#{@repository}/statuses/#{@sha}", payload) } client.post_json("/repos/#{@repository}/check-runs", check_run_payload) - sync_failure_comment + sync_comment + end + + def tophat_install_url + pairs = TOPHAT_RECIPES.flat_map do |recipe| + [ + ["platform", recipe.fetch(:platform)], + ["destination", recipe.fetch(:destination)], + ["app_slug", TOPHAT_APP_SLUG], + ["branch", @branch], + ["workflow", recipe.fetch(:workflow)], + ["artifact_name", recipe.fetch(:artifact_name)] + ] + end + "#{TOPHAT_INSTALL_BASE}?#{URI.encode_www_form(pairs)}" + end + + def comment_body + [COMMENT_MARKER, tophat_install_markdown, markdown_summary].join("\n\n") end def markdown_summary @@ -63,14 +90,16 @@ def commit_status_payloads end end - def failure_comment_body - return nil if failed_results.empty? + private - [COMMENT_MARKER, markdown_summary].join("\n") + def tophat_install_markdown + lines = [] + lines << "## Install this build" + lines << "" + lines << "[Install with Tophat](#{tophat_install_url}) — open on the Mac running Tophat (iOS simulator, iOS device, or Android)." + lines.join("\n") end - private - def check_run_payload conclusion = failed_results.empty? ? "success" : "failure" { @@ -85,21 +114,17 @@ def check_run_payload } end - def sync_failure_comment - body = failure_comment_body - existing = existing_failure_comment - if body - if existing - client.patch_json("/repos/#{@repository}/issues/comments/#{existing.fetch("id")}", {body: body}) - else - client.post_json("/repos/#{@repository}/issues/#{@pr_number}/comments", {body: body}) - end - elsif existing - client.patch_json("/repos/#{@repository}/issues/comments/#{existing.fetch("id")}", {body: "#{COMMENT_MARKER}\n✅ Checkout Kit E2E failures resolved."}) + def sync_comment + body = comment_body + existing = existing_comment + if existing + client.patch_json("/repos/#{@repository}/issues/comments/#{existing.fetch("id")}", {body: body}) + else + client.post_json("/repos/#{@repository}/issues/#{@pr_number}/comments", {body: body}) end end - def existing_failure_comment + def existing_comment issue_comments.find do |comment| comment.fetch("body", "").include?(COMMENT_MARKER) end diff --git a/e2e/scripts/report_e2e_results b/e2e/scripts/report_e2e_results index 9b359ebb..e1bb4e39 100755 --- a/e2e/scripts/report_e2e_results +++ b/e2e/scripts/report_e2e_results @@ -10,6 +10,7 @@ options = { repository: ENV["GITHUB_REPOSITORY"] || "Shopify/checkout-kit", sha: ENV["BITRISE_GIT_COMMIT"], pr_number: ENV["BITRISE_PULL_REQUEST"], + branch: ENV["BITRISE_GIT_BRANCH"], token: ENV["GITHUB_TOKEN"], strict: ENV.fetch("E2E_STRICT", "false") == "true" } @@ -19,6 +20,7 @@ OptionParser.new do |opts| opts.on("--repository REPOSITORY") { |repository| options[:repository] = repository } opts.on("--sha SHA") { |sha| options[:sha] = sha } opts.on("--pr NUMBER") { |number| options[:pr_number] = number } + opts.on("--branch BRANCH") { |branch| options[:branch] = branch } end.parse! pr_number = options[:pr_number].to_s.strip @@ -46,6 +48,7 @@ reporter = E2EGitHubReporter.new( repository: options.fetch(:repository), sha: options.fetch(:sha), pr_number: options.fetch(:pr_number), + branch: options.fetch(:branch), token: options.fetch(:token), strict: options.fetch(:strict) ) diff --git a/scripts/configure_tophat_quick_launch.sh b/scripts/configure_tophat_quick_launch.sh new file mode 100755 index 00000000..b53ae0b5 --- /dev/null +++ b/scripts/configure_tophat_quick_launch.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -euo pipefail + +TOPHATCTL=/Applications/Tophat.app/Contents/MacOS/tophatctl + +if [ ! -x "$TOPHATCTL" ]; then + echo "Tophat is not installed at $TOPHATCTL. Run 'dev up' to install it." >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +"$TOPHATCTL" apps add "$SCRIPT_DIR/tophat/tophat_checkout_kit_ios.json" +"$TOPHATCTL" apps add "$SCRIPT_DIR/tophat/tophat_checkout_kit_android.json" diff --git a/scripts/tophat/tophat_checkout_kit_android.json b/scripts/tophat/tophat_checkout_kit_android.json new file mode 100644 index 00000000..fa9308a3 --- /dev/null +++ b/scripts/tophat/tophat_checkout_kit_android.json @@ -0,0 +1,17 @@ +{ + "id": "checkout-kit-android", + "name": "Checkout Kit", + "recipes": [ + { + "artifactProviderID": "bitrise-branch", + "platformHint": "android", + "launchArguments": [], + "artifactProviderParameters": { + "app_slug": "f51f9054-053e-40f1-81e9-ae727567ae76", + "branch": "main", + "workflow": "e2e-build-react-native-android", + "artifact_name": "app-e2e.apk" + } + } + ] +} diff --git a/scripts/tophat/tophat_checkout_kit_ios.json b/scripts/tophat/tophat_checkout_kit_ios.json new file mode 100644 index 00000000..6673d8ad --- /dev/null +++ b/scripts/tophat/tophat_checkout_kit_ios.json @@ -0,0 +1,30 @@ +{ + "id": "checkout-kit-ios", + "name": "Checkout Kit", + "recipes": [ + { + "artifactProviderID": "bitrise-branch", + "platformHint": "ios", + "destinationHint": "simulator", + "launchArguments": [], + "artifactProviderParameters": { + "app_slug": "f51f9054-053e-40f1-81e9-ae727567ae76", + "branch": "main", + "workflow": "e2e-build-react-native-ios", + "artifact_name": "CheckoutKitReactNativeDemo-Simulator.zip" + } + }, + { + "artifactProviderID": "bitrise-branch", + "platformHint": "ios", + "destinationHint": "device", + "launchArguments": [], + "artifactProviderParameters": { + "app_slug": "f51f9054-053e-40f1-81e9-ae727567ae76", + "branch": "main", + "workflow": "e2e-build-react-native-ios", + "artifact_name": "CheckoutKitReactNativeDemo-Provisioned.ipa" + } + } + ] +} From f06de088255d6f823f160049e6eb5bac7dcde618 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 3 Jul 2026 12:27:39 +0100 Subject: [PATCH 2/4] Remind devs to set Bitrise PAT in Tophat after dev up Assisted-By: devx/6c1e3ad5-96c8-4972-b087-da7ff7b195c3 --- scripts/configure_tophat_quick_launch.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/configure_tophat_quick_launch.sh b/scripts/configure_tophat_quick_launch.sh index b53ae0b5..3bd901d1 100755 --- a/scripts/configure_tophat_quick_launch.sh +++ b/scripts/configure_tophat_quick_launch.sh @@ -13,3 +13,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" "$TOPHATCTL" apps add "$SCRIPT_DIR/tophat/tophat_checkout_kit_ios.json" "$TOPHATCTL" apps add "$SCRIPT_DIR/tophat/tophat_checkout_kit_android.json" + +cat <<'REMINDER' + +==> Tophat Quick Launch items configured for Checkout Kit. + Installs need a Bitrise Personal Access Token: + 1. Create a PAT at https://app.bitrise.io/me/account/security + 2. Add it to Tophat -> Settings -> Extensions -> Bitrise +REMINDER From 4552542406cd91d763030ebe9d8d3eb2a092faa7 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 3 Jul 2026 14:13:49 +0100 Subject: [PATCH 3/4] Make Tophat targets extensible via shared manifest and labeled entries Assisted-By: devx/6c1e3ad5-96c8-4972-b087-da7ff7b195c3 --- e2e/lib/e2e_github_reporter.rb | 32 ++++++++------- e2e/scripts/report_e2e_results | 5 ++- scripts/configure_tophat_quick_launch.sh | 40 ++++++++++++++++++- scripts/tophat/targets.json | 30 ++++++++++++++ .../tophat/tophat_checkout_kit_android.json | 17 -------- scripts/tophat/tophat_checkout_kit_ios.json | 30 -------------- 6 files changed, 89 insertions(+), 65 deletions(-) create mode 100644 scripts/tophat/targets.json delete mode 100644 scripts/tophat/tophat_checkout_kit_android.json delete mode 100644 scripts/tophat/tophat_checkout_kit_ios.json diff --git a/e2e/lib/e2e_github_reporter.rb b/e2e/lib/e2e_github_reporter.rb index fc643f3d..40b87df5 100644 --- a/e2e/lib/e2e_github_reporter.rb +++ b/e2e/lib/e2e_github_reporter.rb @@ -8,15 +8,9 @@ # check runs, and a sticky pull request comment for failures. class E2EGitHubReporter COMMENT_MARKER = "" - TOPHAT_APP_SLUG = "f51f9054-053e-40f1-81e9-ae727567ae76" TOPHAT_INSTALL_BASE = "http://localhost:29070/install/bitrise-branch" - TOPHAT_RECIPES = [ - {platform: "ios", destination: "device", workflow: "e2e-build-react-native-ios", artifact_name: "CheckoutKitReactNativeDemo-Provisioned.ipa"}, - {platform: "ios", destination: "simulator", workflow: "e2e-build-react-native-ios", artifact_name: "CheckoutKitReactNativeDemo-Simulator.zip"}, - {platform: "android", destination: "any", workflow: "e2e-build-react-native-android", artifact_name: "app-e2e.apk"} - ].freeze - def initialize(results, repository:, sha:, pr_number:, branch: nil, token: nil, strict: false) + def initialize(results, repository:, sha:, pr_number:, branch: nil, token: nil, strict: false, app_slug: nil, targets: []) @results = results @repository = repository @sha = sha @@ -24,6 +18,8 @@ def initialize(results, repository:, sha:, pr_number:, branch: nil, token: nil, @branch = branch @token = token @strict = strict + @app_slug = app_slug + @targets = targets end def publish! @@ -32,15 +28,15 @@ def publish! sync_comment end - def tophat_install_url - pairs = TOPHAT_RECIPES.flat_map do |recipe| + def tophat_install_url(target) + pairs = target.fetch("recipes").flat_map do |recipe| [ - ["platform", recipe.fetch(:platform)], - ["destination", recipe.fetch(:destination)], - ["app_slug", TOPHAT_APP_SLUG], + ["platform", recipe.fetch("platform")], + ["destination", recipe.fetch("destination")], + ["app_slug", @app_slug], ["branch", @branch], - ["workflow", recipe.fetch(:workflow)], - ["artifact_name", recipe.fetch(:artifact_name)] + ["workflow", recipe.fetch("workflow")], + ["artifact_name", recipe.fetch("artifact_name")] ] end "#{TOPHAT_INSTALL_BASE}?#{URI.encode_www_form(pairs)}" @@ -96,7 +92,13 @@ def tophat_install_markdown lines = [] lines << "## Install this build" lines << "" - lines << "[Install with Tophat](#{tophat_install_url}) — open on the Mac running Tophat (iOS simulator, iOS device, or Android)." + lines << "Open Tophat, select your target device, then click Install. Links open on the Mac running Tophat." + lines << "" + lines << "| SDK | Install |" + lines << "|---|---|" + @targets.each do |target| + lines << "| #{target.fetch("label")} | [Install with Tophat](#{tophat_install_url(target)}) |" + end lines.join("\n") end diff --git a/e2e/scripts/report_e2e_results b/e2e/scripts/report_e2e_results index e1bb4e39..c7af056d 100755 --- a/e2e/scripts/report_e2e_results +++ b/e2e/scripts/report_e2e_results @@ -43,6 +43,7 @@ if result_paths.empty? end results = result_paths.sort.map { |path| JSON.parse(File.read(path)) } +tophat_manifest = JSON.parse(File.read(File.expand_path("../../scripts/tophat/targets.json", __dir__))) reporter = E2EGitHubReporter.new( results, repository: options.fetch(:repository), @@ -50,7 +51,9 @@ reporter = E2EGitHubReporter.new( pr_number: options.fetch(:pr_number), branch: options.fetch(:branch), token: options.fetch(:token), - strict: options.fetch(:strict) + strict: options.fetch(:strict), + app_slug: tophat_manifest.fetch("app_slug"), + targets: tophat_manifest.fetch("targets") ) reporter.publish! puts reporter.markdown_summary diff --git a/scripts/configure_tophat_quick_launch.sh b/scripts/configure_tophat_quick_launch.sh index 3bd901d1..9326a16e 100755 --- a/scripts/configure_tophat_quick_launch.sh +++ b/scripts/configure_tophat_quick_launch.sh @@ -10,9 +10,45 @@ if [ ! -x "$TOPHATCTL" ]; then fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MANIFEST="$SCRIPT_DIR/tophat/targets.json" -"$TOPHATCTL" apps add "$SCRIPT_DIR/tophat/tophat_checkout_kit_ios.json" -"$TOPHATCTL" apps add "$SCRIPT_DIR/tophat/tophat_checkout_kit_android.json" +RETIRED_IDS=(checkout-kit-ios checkout-kit-android) +for retired_id in "${RETIRED_IDS[@]}"; do + "$TOPHATCTL" apps remove "$retired_id" >/dev/null 2>&1 || true +done + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +jq -c ' + .app_slug as $app_slug + | .default_branch as $branch + | .targets[] + | { + id: ("checkout-kit-" + .id), + name: ("Checkout Kit (" + .label + ")"), + recipes: [ + .recipes[] + | { + artifactProviderID: "bitrise-branch", + platformHint: .platform, + launchArguments: [], + artifactProviderParameters: { + app_slug: $app_slug, + branch: $branch, + workflow: .workflow, + artifact_name: .artifact_name + } + } + + (if .destination == "any" then {} else {destinationHint: .destination} end) + ] + } +' "$MANIFEST" | while IFS= read -r target_entry; do + entry_id="$(printf '%s' "$target_entry" | jq -r '.id')" + entry_file="$TMP_DIR/$entry_id.json" + printf '%s' "$target_entry" > "$entry_file" + "$TOPHATCTL" apps add "$entry_file" +done cat <<'REMINDER' diff --git a/scripts/tophat/targets.json b/scripts/tophat/targets.json new file mode 100644 index 00000000..b63bc770 --- /dev/null +++ b/scripts/tophat/targets.json @@ -0,0 +1,30 @@ +{ + "app_slug": "f51f9054-053e-40f1-81e9-ae727567ae76", + "default_branch": "main", + "targets": [ + { + "id": "react-native", + "label": "React Native", + "recipes": [ + { + "platform": "ios", + "destination": "device", + "workflow": "e2e-build-react-native-ios", + "artifact_name": "CheckoutKitReactNativeDemo-Provisioned.ipa" + }, + { + "platform": "ios", + "destination": "simulator", + "workflow": "e2e-build-react-native-ios", + "artifact_name": "CheckoutKitReactNativeDemo-Simulator.zip" + }, + { + "platform": "android", + "destination": "any", + "workflow": "e2e-build-react-native-android", + "artifact_name": "app-e2e.apk" + } + ] + } + ] +} diff --git a/scripts/tophat/tophat_checkout_kit_android.json b/scripts/tophat/tophat_checkout_kit_android.json deleted file mode 100644 index fa9308a3..00000000 --- a/scripts/tophat/tophat_checkout_kit_android.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "checkout-kit-android", - "name": "Checkout Kit", - "recipes": [ - { - "artifactProviderID": "bitrise-branch", - "platformHint": "android", - "launchArguments": [], - "artifactProviderParameters": { - "app_slug": "f51f9054-053e-40f1-81e9-ae727567ae76", - "branch": "main", - "workflow": "e2e-build-react-native-android", - "artifact_name": "app-e2e.apk" - } - } - ] -} diff --git a/scripts/tophat/tophat_checkout_kit_ios.json b/scripts/tophat/tophat_checkout_kit_ios.json deleted file mode 100644 index 6673d8ad..00000000 --- a/scripts/tophat/tophat_checkout_kit_ios.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "id": "checkout-kit-ios", - "name": "Checkout Kit", - "recipes": [ - { - "artifactProviderID": "bitrise-branch", - "platformHint": "ios", - "destinationHint": "simulator", - "launchArguments": [], - "artifactProviderParameters": { - "app_slug": "f51f9054-053e-40f1-81e9-ae727567ae76", - "branch": "main", - "workflow": "e2e-build-react-native-ios", - "artifact_name": "CheckoutKitReactNativeDemo-Simulator.zip" - } - }, - { - "artifactProviderID": "bitrise-branch", - "platformHint": "ios", - "destinationHint": "device", - "launchArguments": [], - "artifactProviderParameters": { - "app_slug": "f51f9054-053e-40f1-81e9-ae727567ae76", - "branch": "main", - "workflow": "e2e-build-react-native-ios", - "artifact_name": "CheckoutKitReactNativeDemo-Provisioned.ipa" - } - } - ] -} From 96fe2c9ba7ae05e5d381d2a8b25653c613bb07ea Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 3 Jul 2026 17:02:13 +0100 Subject: [PATCH 4/4] Document Tophat E2E reporter behavior Assisted-By: devx/9ae89f69-61a5-46e3-8faf-006d105b0cdd --- .github/CONTRIBUTING.md | 45 +++++ dev.yml | 8 +- e2e/BITRISE.md | 2 +- e2e/lib/e2e_github_reporter.rb | 2 +- e2e/scripts/build_react_native_android | 4 + scripts/configure_tophat_quick_launch.sh | 55 +------ scripts/tophat/common.rb | 97 +++++++++++ scripts/tophat/configure_quick_launch | 29 ++++ scripts/tophat/dev_tophat | 201 +++++++++++++++++++++++ 9 files changed, 386 insertions(+), 57 deletions(-) create mode 100644 scripts/tophat/common.rb create mode 100755 scripts/tophat/configure_quick_launch create mode 100755 scripts/tophat/dev_tophat diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4ebf06fc..8f8f4b75 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -66,6 +66,51 @@ React Native sample apps can be run against local in-repo SDK sources with `dev rn ios --local` or `dev rn android --local`. The Web sample accepts a checkout URL directly and does not use the shared storefront credential files. +### Testing PR builds with Tophat + +[Tophat](https://github.com/Shopify/tophat) is a macOS menu-bar app that +installs testable builds onto a simulator, emulator, or connected device that +your Mac controls. Checkout Kit's E2E pipeline (Bitrise) produces the +installable artifacts, and Tophat downloads them. Because Tophat installs onto +the device your Mac controls, install links must be opened on that Mac — +scanning a QR code with a phone does not work with Tophat. + +**First-time setup.** `dev up` installs Tophat and seeds Quick Launch entries. +Tophat needs a Bitrise Personal Access Token to download build artifacts: + +1. Create a PAT at https://app.bitrise.io/me/account/security +2. Add it in Tophat -> Settings -> Extensions -> Bitrise + +There are three ways to install a build: + +1. **Quick Launch (latest `main`)** — `dev up` seeds a `Checkout Kit (React + Native)` entry (from `scripts/tophat/targets.json`) that installs the latest + successful `main` build. Select a device in Tophat's menu, then pick the + entry. +2. **Per-PR comment** — each PR gets a sticky comment with an `Install with + Tophat` link per SDK target for that PR's branch. Open Tophat, select your + target device, then click the link on the Mac running Tophat. +3. **`dev tophat` command** — installs a specific PR's build directly to a + device, targeting the device explicitly so you do not need to pre-select one + in Tophat: + + ```bash + dev tophat # pick a PR, then what to test, then a device + dev tophat 382 # PR 382 + dev tophat 382 react-native-ios # skip the "what to test" prompt + dev tophat https://github.com/Shopify/checkout-kit/pull/382 + ``` + + It resolves the PR's branch, asks what to test (e.g. React Native iOS / + Android), reuses a running device that matches or lets you pick one with + `fzf`, then installs the matching artifact. Set `TOPHAT_DRY_RUN=1` to print + the generated install config without installing. + +**Adding a new SDK target.** Add an entry to `scripts/tophat/targets.json` with +an `id`, `label`, and `recipes` (each a `platform`, `destination`, Bitrise +`workflow`, and `artifact_name`). It automatically flows into the Quick Launch +entries, the per-PR comment table, and `dev tophat`. + Sample app storefront configuration is generated from the repo-root `.env`. Shopify employees get this through `dev up`. External contributors can copy `.env.example` to `.env`, fill in local storefront values, then run diff --git a/dev.yml b/dev.yml index ed06a836..6e2621f6 100644 --- a/dev.yml +++ b/dev.yml @@ -6,6 +6,7 @@ up: - mint - xcbeautify - jq + - fzf - swiftlint - swiftformat - sccache @@ -35,7 +36,7 @@ up: - tophat_mobile - run_always: name: Configure Tophat Quick Launch items - run: sh scripts/configure_tophat_quick_launch.sh + run: ruby scripts/tophat/configure_quick_launch open: "GitHub": "https://github.com/Shopify/checkout-kit" @@ -78,6 +79,11 @@ commands: desc: Copy the root .env into the current worktree so `dev up` can regenerate sample config run: ./scripts/copy_worktree_env + tophat: + desc: "Install a PR's build to a device via Tophat. Usage: dev tophat [] []" + syntax: "[] []" + run: ruby scripts/tophat/dev_tophat "$@" + format: desc: Auto-format and apply safe lint autocorrections across supported workspaces aliases: [fix] diff --git a/e2e/BITRISE.md b/e2e/BITRISE.md index d29e2cd6..25f196b3 100644 --- a/e2e/BITRISE.md +++ b/e2e/BITRISE.md @@ -114,7 +114,7 @@ The `e2e-report` workflow creates commit statuses, Check Runs, and sticky PR com The Bitrise project has **Project settings > Repository > Extend GitHub App permissions to builds** enabled. Bitrise exposes the build-scoped GitHub App token as `GIT_HTTP_PASSWORD`; the report workflow maps it to `GITHUB_TOKEN` before running `e2e/scripts/report_e2e_results`. -Green runs update statuses and Check Runs without creating new PR comments. Failing runs update a sticky PR failure comment with direct BrowserStack evidence links. +Every run maintains a single sticky PR comment (create-or-update via a marker). The comment always includes an "Install with Tophat" link per SDK target and the E2E results table; failing runs add direct BrowserStack evidence links. The install links and Quick Launch entries are driven by `scripts/tophat/targets.json`; see the Tophat section in `.github/CONTRIBUTING.md`. ## Caching diff --git a/e2e/lib/e2e_github_reporter.rb b/e2e/lib/e2e_github_reporter.rb index 40b87df5..06b3ceab 100644 --- a/e2e/lib/e2e_github_reporter.rb +++ b/e2e/lib/e2e_github_reporter.rb @@ -5,7 +5,7 @@ require_relative "json_http_client" # Publishes normalized E2E run results back to GitHub as commit statuses, -# check runs, and a sticky pull request comment for failures. +# check runs, and a sticky pull request comment with Tophat install links. class E2EGitHubReporter COMMENT_MARKER = "" TOPHAT_INSTALL_BASE = "http://localhost:29070/install/bitrise-branch" diff --git a/e2e/scripts/build_react_native_android b/e2e/scripts/build_react_native_android index 871e9f38..f3a4bedc 100755 --- a/e2e/scripts/build_react_native_android +++ b/e2e/scripts/build_react_native_android @@ -19,5 +19,9 @@ cd sample/android android_apk="$PWD/app/build/outputs/apk/e2e/app-e2e.apk" test -f "$android_apk" +e2e_dir="$(e2e_deploy_dir)" +cp "$android_apk" "$e2e_dir/app-e2e.apk" +android_apk="$e2e_dir/app-e2e.apk" + e2e_log "Publishing Android APK path" envman add --key E2E_REACT_NATIVE_ANDROID_APP_PATH --value "$android_apk" diff --git a/scripts/configure_tophat_quick_launch.sh b/scripts/configure_tophat_quick_launch.sh index 9326a16e..4457d7e0 100755 --- a/scripts/configure_tophat_quick_launch.sh +++ b/scripts/configure_tophat_quick_launch.sh @@ -2,58 +2,5 @@ set -euo pipefail -TOPHATCTL=/Applications/Tophat.app/Contents/MacOS/tophatctl - -if [ ! -x "$TOPHATCTL" ]; then - echo "Tophat is not installed at $TOPHATCTL. Run 'dev up' to install it." >&2 - exit 1 -fi - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -MANIFEST="$SCRIPT_DIR/tophat/targets.json" - -RETIRED_IDS=(checkout-kit-ios checkout-kit-android) -for retired_id in "${RETIRED_IDS[@]}"; do - "$TOPHATCTL" apps remove "$retired_id" >/dev/null 2>&1 || true -done - -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -jq -c ' - .app_slug as $app_slug - | .default_branch as $branch - | .targets[] - | { - id: ("checkout-kit-" + .id), - name: ("Checkout Kit (" + .label + ")"), - recipes: [ - .recipes[] - | { - artifactProviderID: "bitrise-branch", - platformHint: .platform, - launchArguments: [], - artifactProviderParameters: { - app_slug: $app_slug, - branch: $branch, - workflow: .workflow, - artifact_name: .artifact_name - } - } - + (if .destination == "any" then {} else {destinationHint: .destination} end) - ] - } -' "$MANIFEST" | while IFS= read -r target_entry; do - entry_id="$(printf '%s' "$target_entry" | jq -r '.id')" - entry_file="$TMP_DIR/$entry_id.json" - printf '%s' "$target_entry" > "$entry_file" - "$TOPHATCTL" apps add "$entry_file" -done - -cat <<'REMINDER' - -==> Tophat Quick Launch items configured for Checkout Kit. - Installs need a Bitrise Personal Access Token: - 1. Create a PAT at https://app.bitrise.io/me/account/security - 2. Add it to Tophat -> Settings -> Extensions -> Bitrise -REMINDER +ruby "$SCRIPT_DIR/tophat/configure_quick_launch" "$@" diff --git a/scripts/tophat/common.rb b/scripts/tophat/common.rb new file mode 100644 index 00000000..9b269871 --- /dev/null +++ b/scripts/tophat/common.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "json" +require "open3" + +module CheckoutKitTophat + TOPHATCTL = "/Applications/Tophat.app/Contents/MacOS/tophatctl" + MANIFEST = File.expand_path("targets.json", __dir__) + BITRISE_PAT_URL = "https://app.bitrise.io/me/account/security" + PLATFORM_LABELS = {"ios" => "iOS", "android" => "Android"}.freeze + module_function + + def abort_with(message) + warn message + exit 1 + end + + def capture(*command) + stdout, status = Open3.capture2(*command) + abort_with("Command failed: #{command.join(" ")}") unless status.success? + stdout + end + + def load_manifest + JSON.parse(File.read(MANIFEST)) + end + + def require_tophat! + abort_with("Tophat is not installed at #{TOPHATCTL}. Run 'dev up' to install it.") unless File.executable?(TOPHATCTL) + end + + def install_options(manifest) + manifest.fetch("targets").flat_map do |target| + platforms = target.fetch("recipes").map { |recipe| recipe.fetch("platform") }.uniq + platforms.map do |platform| + { + "id" => "#{target.fetch("id")}-#{platform}", + "label" => "#{target.fetch("label")} (#{PLATFORM_LABELS.fetch(platform, platform)})", + "target" => target, + "platform" => platform + } + end + end + end + + def available_target_ids(manifest) + install_options(manifest).map { |option| option.fetch("id") } + end + + def artifact_provider_parameters(recipe, app_slug, branch) + { + "app_slug" => app_slug, + "branch" => branch, + "workflow" => recipe.fetch("workflow"), + "artifact_name" => recipe.fetch("artifact_name") + } + end + + def bitrise_branch_recipe(recipe, app_slug, branch) + { + "artifactProviderID" => "bitrise-branch", + "launchArguments" => [], + "artifactProviderParameters" => artifact_provider_parameters(recipe, app_slug, branch) + } + end + + def install_config(recipe, device, app_slug, branch) + [ + bitrise_branch_recipe(recipe, app_slug, branch).merge( + "device" => { + "name" => device.fetch("name"), + "platform" => device.fetch("platform"), + "runtimeVersion" => device.fetch("runtimeVersion") + } + ) + ] + end + + def quick_launch_entry(target, app_slug, branch) + { + "id" => "checkout-kit-#{target.fetch("id")}", + "name" => "Checkout Kit (#{target.fetch("label")})", + "recipes" => target.fetch("recipes").map do |recipe| + quick_launch_recipe(recipe, app_slug, branch) + end + } + end + + def quick_launch_recipe(recipe, app_slug, branch) + config = bitrise_branch_recipe(recipe, app_slug, branch).merge( + "platformHint" => recipe.fetch("platform") + ) + destination = recipe.fetch("destination") + config["destinationHint"] = destination unless destination == "any" + config + end +end diff --git a/scripts/tophat/configure_quick_launch b/scripts/tophat/configure_quick_launch new file mode 100755 index 00000000..a8e9561e --- /dev/null +++ b/scripts/tophat/configure_quick_launch @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "tempfile" +require_relative "common" + +def add_quick_launch_entry(entry) + Tempfile.create(["tophat-quick-launch", ".json"]) do |file| + file.write(JSON.generate(entry)) + file.flush + system(CheckoutKitTophat::TOPHATCTL, "apps", "add", file.path) || CheckoutKitTophat.abort_with("Failed to configure Tophat Quick Launch entry #{entry.fetch("id")}") + end +end + +CheckoutKitTophat.require_tophat! +manifest = CheckoutKitTophat.load_manifest + +manifest.fetch("targets").each do |target| + entry = CheckoutKitTophat.quick_launch_entry(target, manifest.fetch("app_slug"), manifest.fetch("default_branch")) + add_quick_launch_entry(entry) +end + +puts <<~REMINDER + + ==> Tophat Quick Launch items configured for Checkout Kit. + Installs need a Bitrise Personal Access Token: + 1. Create a PAT at #{CheckoutKitTophat::BITRISE_PAT_URL} + 2. Add it to Tophat -> Settings -> Extensions -> Bitrise +REMINDER diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat new file mode 100755 index 00000000..f569c20a --- /dev/null +++ b/scripts/tophat/dev_tophat @@ -0,0 +1,201 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "tempfile" +require_relative "common" + +def abort_with(message) + CheckoutKitTophat.abort_with(message) +end + +def capture(*command) + CheckoutKitTophat.capture(*command) +end + +def fzf_select(lines, prompt:, preview: nil) + args = ["fzf", "--prompt", "#{prompt} > ", "--height", "40%", "--reverse", "--delimiter", "\t", "--with-nth", "2.."] + args += ["--preview", preview, "--preview-window", "down,40%"] if preview + selection, status = Open3.capture2(*args, stdin_data: lines.join("\n")) + abort_with("Nothing selected.") unless status.success? && !selection.strip.empty? + selection.strip +end + +def resolve_pr_number(argument) + if argument + return Integer(argument[%r{/pull/(\d+)}, 1]) if argument.include?("/pull/") + abort_with("Invalid pull request '#{argument}'. Pass a PR number or pull request URL.") unless argument.match?(/\A\d+\z/) + return Integer(argument) + end + + prs = JSON.parse(capture("gh", "pr", "list", "--json", "number,title,author,headRefName", "--limit", "50")) + abort_with("No open pull requests found.") if prs.empty? + + lines = prs.map do |pr| + number = pr.fetch("number") + display = "##{number} #{pr.fetch("title")} @#{pr.dig("author", "login")} #{pr.fetch("headRefName")}" + "#{number}\t#{display}" + end + selection = fzf_select(lines, prompt: "Pull request", preview: "gh pr view {1}") + Integer(selection.split("\t", 2).first) +end + +def branch_for(pr_number) + capture("gh", "pr", "view", pr_number.to_s, "--json", "headRefName", "-q", ".headRefName").strip +end + +def select_option(options, requested_id) + if requested_id + option = options.find { |candidate| candidate.fetch("id") == requested_id } + abort_with("Unknown target '#{requested_id}'. Available: #{options.map { |o| o.fetch("id") }.join(", ")}") unless option + return option + end + return options.first if options.length == 1 + + lines = options.map { |option| "#{option.fetch("id")}\t#{option.fetch("label")}" } + selection = fzf_select(lines, prompt: "What to test") + selected_id = selection.split("\t", 2).first + options.find { |option| option.fetch("id") == selected_id } +end + +def select_device(platform) + devices = JSON.parse(capture(CheckoutKitTophat::TOPHATCTL, "list", "devices", "--json")) + matching = devices.select { |device| device.fetch("platform") == platform } + abort_with("No #{CheckoutKitTophat::PLATFORM_LABELS.fetch(platform, platform)} devices are available to Tophat.") if matching.empty? + + ready = matching.select { |device| device.fetch("state") == "ready" } + return ready.first if ready.length == 1 + + candidates = ready.empty? ? matching : ready + lines = candidates.each_with_index.map do |device, index| + "#{index}\t#{device.fetch("name")} #{device.fetch("runtimeVersion")} (#{device.fetch("type")}, #{device.fetch("state")})" + end + selection = fzf_select(lines, prompt: "Device") + candidates.fetch(Integer(selection.split("\t", 2).first)) +end + +def recipe_for(target, device) + recipe = target.fetch("recipes").find do |candidate| + candidate.fetch("platform") == device.fetch("platform") && + (candidate.fetch("destination") == device.fetch("type") || candidate.fetch("destination") == "any") + end + abort_with("No #{device.fetch("type")} recipe for #{target.fetch("label")} on #{device.fetch("platform")}.") unless recipe + recipe +end + +def bitrise_auth_error?(output) + normalized = output.downcase + normalized.include?("bitrise personal access token") || + normalized.include?("personal access token is required") || + normalized.include?("access token used to authenticate with bitrise is invalid") || + normalized.include?("authenticate with bitrise") || + (normalized.include?("unauthorized") && normalized.include?("bitrise")) || + normalized.match?(/\b(401|403)\b/) +end + +def prompt_for_bitrise_token_setup + puts <<~MESSAGE + + Tophat could not authenticate with Bitrise. + + Tophat needs a Bitrise Personal Access Token to download Checkout Kit build artifacts. + Create a token, then add it in Tophat -> Settings -> Extensions -> Bitrise. + + Press Enter to open the Bitrise token page, or Ctrl-C to cancel. + MESSAGE + STDIN.gets || abort_with("Cancelled.") + system("open", CheckoutKitTophat::BITRISE_PAT_URL) + puts "Add the token in Tophat -> Settings -> Extensions -> Bitrise, then press Enter to retry." + STDIN.gets || abort_with("Cancelled.") +end + +def run_tophat_install(config_path) + output = +"" + status = nil + + Open3.popen2e(CheckoutKitTophat::TOPHATCTL, "install", config_path) do |stdin, stdout_and_stderr, wait_thread| + stdin.close + stdout_and_stderr.each do |line| + output << line + print line + end + status = wait_thread.value + end + + [output, status] +end + +def install_with_tophat(config_path) + output, status = run_tophat_install(config_path) + return true if status.success? + + if bitrise_auth_error?(output) + prompt_for_bitrise_token_setup + _retry_output, retry_status = run_tophat_install(config_path) + return true if retry_status.success? + end + + false +end + +def help_requested? + ARGV.include?("--help") || ARGV.include?("-h") +end + +def print_help(manifest) + targets = CheckoutKitTophat.available_target_ids(manifest) + puts <<~HELP + Usage: dev tophat [] [] + + Install a pull request build to a device via Tophat. + + Arguments: + Optional pull request number or GitHub pull request URL. + If omitted, choose from open pull requests with fzf. + Optional target ID. If omitted, choose with fzf. + + Options: + -h, --help Show this help. + + Available targets: + #{targets.join("\n ")} + + Examples: + dev tophat + dev tophat 382 + dev tophat 382 react-native-ios + dev tophat https://github.com/Shopify/checkout-kit/pull/382 + + Environment: + TOPHAT_DRY_RUN=1 Print the generated Tophat install config without installing. + HELP +end + +manifest = CheckoutKitTophat.load_manifest +if help_requested? + print_help(manifest) + exit 0 +end + +CheckoutKitTophat.require_tophat! + +pr_number = resolve_pr_number(ARGV[0]) +branch = branch_for(pr_number) +option = select_option(CheckoutKitTophat.install_options(manifest), ARGV[1]) +device = select_device(option.fetch("platform")) +recipe = recipe_for(option.fetch("target"), device) +config = CheckoutKitTophat.install_config(recipe, device, manifest.fetch("app_slug"), branch) + +puts "==> Installing #{option.fetch("label")} from PR ##{pr_number} (#{branch})" +puts " Device: #{device.fetch("name")} #{device.fetch("runtimeVersion")} (#{device.fetch("type")})" +puts " Artifact: #{recipe.fetch("artifact_name")}" + +if ENV["TOPHAT_DRY_RUN"] == "1" + puts JSON.pretty_generate(config) + exit 0 +end + +Tempfile.create(["tophat-install", ".json"]) do |file| + file.write(JSON.generate(config)) + file.flush + install_with_tophat(file.path) || abort_with("Tophat install failed.") +end