Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +86 to +89

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently we aren't building on main - so this wont work until we decide to do that, if we decide that main builds are not desirable then we can just remove this quick launch open

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
Expand Down
10 changes: 10 additions & 0 deletions dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ up:
- mint
- xcbeautify
- jq
- fzf
- swiftlint
- swiftformat
- sccache
Expand All @@ -32,6 +33,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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a predefined dev command that installs and sets up tophat!

- run_always:
name: Configure Tophat Quick Launch items
run: ruby scripts/tophat/configure_quick_launch

open:
"GitHub": "https://github.com/Shopify/checkout-kit"
Expand Down Expand Up @@ -74,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 [<pr-number-or-url>] [<target>]"
syntax: "[<pr-number-or-url>] [<target>]"
run: ruby scripts/tophat/dev_tophat "$@"

format:
desc: Auto-format and apply safe lint autocorrections across supported workspaces
aliases: [fix]
Expand Down
2 changes: 1 addition & 1 deletion e2e/BITRISE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 47 additions & 20 deletions e2e/lib/e2e_github_reporter.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,49 @@
# 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.
# check runs, and a sticky pull request comment with Tophat install links.
class E2EGitHubReporter
COMMENT_MARKER = "<!-- checkout-kit-e2e-report -->"
TOPHAT_INSTALL_BASE = "http://localhost:29070/install/bitrise-branch"

@kieran-osgood-shopify kieran-osgood-shopify Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've opted for using this localhost url (which tophat runs on by default) due to security measures github has in place to strip deep links as valid links to prevent hijacking

Using a deep link url like tophat://
image.png

Will result in an empty space in the saved and rendered output
image.png


def initialize(results, repository:, sha:, pr_number:, 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
@pr_number = pr_number
@branch = branch
@token = token
@strict = strict
@app_slug = app_slug
@targets = targets
end

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(target)
pairs = target.fetch("recipes").flat_map do |recipe|
[
["platform", recipe.fetch("platform")],
["destination", recipe.fetch("destination")],
["app_slug", @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
Expand Down Expand Up @@ -63,14 +86,22 @@ 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 << "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

private

def check_run_payload
conclusion = failed_results.empty? ? "success" : "failure"
{
Expand All @@ -85,21 +116,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
Expand Down
4 changes: 4 additions & 0 deletions e2e/scripts/build_react_native_android
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 7 additions & 1 deletion e2e/scripts/report_e2e_results
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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
Expand All @@ -41,13 +43,17 @@ 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),
sha: options.fetch(:sha),
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
6 changes: 6 additions & 0 deletions scripts/configure_tophat_quick_launch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/bash

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ruby "$SCRIPT_DIR/tophat/configure_quick_launch" "$@"
97 changes: 97 additions & 0 deletions scripts/tophat/common.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions scripts/tophat/configure_quick_launch
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading