From 6d0adec9a434e74ab9ffbe103106f90630f0a15b Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 09:44:17 +0100 Subject: [PATCH 01/10] Add Bitrise client for Tophat artifact discovery Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- scripts/tophat/bitrise_client.rb | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 scripts/tophat/bitrise_client.rb diff --git a/scripts/tophat/bitrise_client.rb b/scripts/tophat/bitrise_client.rb new file mode 100644 index 00000000..7e12267f --- /dev/null +++ b/scripts/tophat/bitrise_client.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" + +class BitriseClient + API_HOST = "api.bitrise.io" + API_BASE = "/v0.1" + + def initialize(token:, app_slug:) + @token = token + @app_slug = app_slug + end + + def authorized? + get("#{API_BASE}/me").is_a?(Net::HTTPSuccess) + end + + def latest_build(branch:, workflow:) + query = URI.encode_www_form(branch: branch, workflow: workflow, limit: 1) + get_json("#{API_BASE}/apps/#{@app_slug}/builds?#{query}").fetch("data", []).first + end + + def artifact_titles(build_slug) + path = "#{API_BASE}/apps/#{@app_slug}/builds/#{build_slug}/artifacts" + get_json(path).fetch("data", []).map { |artifact| artifact.fetch("title") } + end + + private + + def get_json(path) + response = get(path) + raise "Bitrise request failed #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess) + JSON.parse(response.body) + end + + def get(path) + request = Net::HTTP::Get.new(path) + request["Authorization"] = @token + Net::HTTP.start(API_HOST, 443, use_ssl: true) { |http| http.request(request) } + end +end From 939e34c654b2d0a39ce3c1474e8c6a9106b29e8a Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 09:52:02 +0100 Subject: [PATCH 02/10] Add Bitrise token bootstrap and keychain propagation for Tophat --- scripts/tophat/bitrise_auth.rb | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 scripts/tophat/bitrise_auth.rb diff --git a/scripts/tophat/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb new file mode 100644 index 00000000..6b1578cb --- /dev/null +++ b/scripts/tophat/bitrise_auth.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "io/console" +require "open3" +require_relative "common" +require_relative "bitrise_client" + +module BitriseAuth + KEYCHAIN_SERVICE = "com.shopify.checkout-kit.tophat" + KEYCHAIN_ACCOUNT = "bitrise-pat" + TOPHAT_KEYCHAIN_SERVICE = "com.shopify.Tophat.TophatBitriseExtension" + TOPHAT_KEYCHAIN_ACCOUNT = "TophatBitrisePersonalAccessToken" + TOPHAT_APP = "/Applications/Tophat.app" + module_function + + def resolve_token(app_slug) + stored = read_keychain(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + return stored if stored && authorized?(stored, app_slug) + + bootstrap_token(app_slug) + end + + def authorized?(token, app_slug) + BitriseClient.new(token: token, app_slug: app_slug).authorized? + end + + def bootstrap_token(app_slug) + open_token_page + token = prompt_until_valid(app_slug) + store_token(token) + propagate_to_tophat(token) + token + end + + def open_token_page + puts <<~MESSAGE + + A Bitrise Personal Access Token is required to check which builds are ready. + Opening the Bitrise token page. Create a token, then paste it below. + + MESSAGE + system("open", CheckoutKitTophat::BITRISE_PAT_URL) + end + + def prompt_until_valid(app_slug) + loop do + token = prompt_token + CheckoutKitTophat.abort_with("Cancelled.") if token.nil? || token.empty? + return token if authorized?(token, app_slug) + + warn "Bitrise did not accept that token. Check it was copied correctly and try again, or press Ctrl-C to cancel." + end + end + + def prompt_token + print "Paste your Bitrise Personal Access Token (input hidden): " + token = STDIN.tty? ? STDIN.noecho(&:gets) : STDIN.gets + puts + token&.strip + end + + def store_token(token) + write_keychain(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, token) || + CheckoutKitTophat.abort_with("Could not store the Bitrise token in your keychain.") + end + + def propagate_to_tophat(token) + return unless File.exist?(TOPHAT_APP) + + puts <<~MESSAGE + Saving the token into Tophat so installs can download build artifacts. + macOS may ask to let "security" update Tophat's keychain entry; choose Always Allow. + To skip this, add the token yourself in Tophat -> Settings -> Extensions -> Bitrise. + + MESSAGE + return if write_keychain(TOPHAT_KEYCHAIN_SERVICE, TOPHAT_KEYCHAIN_ACCOUNT, token, trusted_app: TOPHAT_APP) + + warn <<~MESSAGE + Could not save the token into Tophat automatically. + Add it manually in Tophat -> Settings -> Extensions -> Bitrise before installing. + MESSAGE + end + + def read_keychain(service, account) + stdout, status = Open3.capture2e("security", "find-generic-password", "-s", service, "-a", account, "-w") + return nil unless status.success? + + value = stdout.strip + value.empty? ? nil : value + end + + def write_keychain(service, account, token, trusted_app: nil) + args = ["security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", token] + args += ["-T", trusted_app] if trusted_app + system(*args, out: File::NULL, err: File::NULL) + end +end From 9ba50afe9f9c412b5d9fc466710d7ac7ed6a7098 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 10:02:43 +0100 Subject: [PATCH 03/10] Filter Tophat targets by available Bitrise artifacts --- .github/CONTRIBUTING.md | 28 +++-- scripts/tophat/configure_quick_launch | 4 +- scripts/tophat/dev_tophat | 144 +++++++++++++++++++++----- 3 files changed, 143 insertions(+), 33 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8f8f4b75..4cf82a18 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -76,10 +76,14 @@ 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 +Installs need a Bitrise Personal Access Token to download build artifacts. The +first time you run `dev tophat` and no token is stored, it opens the Bitrise +token page, prompts you to paste a token (input hidden), stores it in your +login keychain, and saves it into Tophat for you (macOS may ask to let +`security` update Tophat's keychain entry — choose Always Allow). If you would +rather configure it yourself, create a PAT at +https://app.bitrise.io/me/account/security and add it in +Tophat -> Settings -> Extensions -> Bitrise. There are three ways to install a build: @@ -101,10 +105,18 @@ There are three ways to install a build: 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. + It resolves the PR's branch, then queries Bitrise for that branch and only + offers targets whose latest build succeeded and produced the expected + artifact, so you cannot pick a target that has no installable build. If no + target is ready it explains why (draft PRs do not trigger the pipeline, a + build is still running, or a build failed) and exits. Pass `--wait` to block + until in-progress builds finish, then install. It then asks what to test + (e.g. React Native iOS / Android), reuses a running device that matches or + lets you pick one with `fzf`, and installs the matching artifact. + + Set `TOPHAT_DRY_RUN=1` to print the generated install config without + installing, or `TOPHAT_SKIP_ARTIFACT_CHECK=1` to skip the Bitrise artifact + filtering and offer every target. **Adding a new SDK target.** Add an entry to `scripts/tophat/targets.json` with an `id`, `label`, and `recipes` (each a `platform`, `destination`, Bitrise diff --git a/scripts/tophat/configure_quick_launch b/scripts/tophat/configure_quick_launch index a8e9561e..cd83b533 100755 --- a/scripts/tophat/configure_quick_launch +++ b/scripts/tophat/configure_quick_launch @@ -23,7 +23,9 @@ end puts <<~REMINDER ==> Tophat Quick Launch items configured for Checkout Kit. - Installs need a Bitrise Personal Access Token: + Installs need a Bitrise Personal Access Token. The first run of + 'dev tophat' sets one up for you (it prompts for a token and saves it + into Tophat). To configure it manually instead: 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 index f569c20a..c7ea5060 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -3,6 +3,11 @@ require "tempfile" require_relative "common" +require_relative "bitrise_client" +require_relative "bitrise_auth" + +WAIT_POLL_SECONDS = 30 +WAIT_TIMEOUT_SECONDS = 1800 def abort_with(message) CheckoutKitTophat.abort_with(message) @@ -20,6 +25,87 @@ def fzf_select(lines, prompt:, preview: nil) selection.strip end +def extract_flag(flag) + present = ARGV.include?(flag) + ARGV.delete(flag) + present +end + +def skip_artifact_check? + ENV["TOPHAT_SKIP_ARTIFACT_CHECK"] == "1" +end + +def recipes_for_option(option) + option.fetch("target").fetch("recipes").select { |recipe| recipe.fetch("platform") == option.fetch("platform") } +end + +def workflow_state(client, branch, workflow, artifact_names) + build = client.latest_build(branch: branch, workflow: workflow) + return :missing unless build + return :running if build.fetch("status") == 0 + return :failed unless build.fetch("status") == 1 + + titles = client.artifact_titles(build.fetch("slug")) + artifact_names.all? { |name| titles.include?(name) } ? :ready : :missing +end + +def combine_states(states) + return :ready if states.all? { |state| state == :ready } + return :running if states.include?(:running) + return :failed if states.include?(:failed) + :missing +end + +def option_state(client, branch, option) + states = recipes_for_option(option).group_by { |recipe| recipe.fetch("workflow") }.map do |workflow, recipes| + workflow_state(client, branch, workflow, recipes.map { |recipe| recipe.fetch("artifact_name") }) + end + combine_states(states) +end + +def evaluate_options(client, branch, options) + options.map { |option| {option: option, state: option_state(client, branch, option)} } +end + +def wait_for_ready(client, branch, options) + deadline = Time.now + WAIT_TIMEOUT_SECONDS + loop do + evaluated = evaluate_options(client, branch, options) + running = evaluated.select { |entry| entry.fetch(:state) == :running } + return evaluated if running.empty? || Time.now >= deadline + + puts "==> Waiting for Bitrise builds to finish: #{running.map { |entry| entry.fetch(:option).fetch("label") }.join(", ")}" + sleep WAIT_POLL_SECONDS + end +end + +def unavailable_message(evaluated, pr_number, branch) + states = evaluated.map { |entry| entry.fetch(:state) } + guidance = + if states.include?(:running) + "Bitrise is still building. Re-run once it finishes, or pass --wait to block until artifacts are ready." + elsif states.all? { |state| state == :missing } + "No Bitrise builds found for this branch. Draft PRs do not trigger the pipeline; mark the PR ready for review or push a commit to start a build." + else + "The build workflow(s) failed, so no artifact was produced. Check the pipeline in Bitrise and re-run it." + end + "No installable build artifacts for PR ##{pr_number} (#{branch}).\n#{guidance}" +end + +def filter_available_options(options, manifest, branch, pr_number, wait:) + app_slug = manifest.fetch("app_slug") + client = BitriseClient.new(token: BitriseAuth.resolve_token(app_slug), app_slug: app_slug) + evaluated = evaluate_options(client, branch, options) + evaluated = wait_for_ready(client, branch, options) if wait && evaluated.any? { |entry| entry.fetch(:state) == :running } + ready = evaluated.select { |entry| entry.fetch(:state) == :ready }.map { |entry| entry.fetch(:option) } + return ready unless ready.empty? + + abort_with(unavailable_message(evaluated, pr_number, branch)) +rescue => error + warn "Could not verify Bitrise artifacts (#{error.message}). Showing all targets." + options +end + def resolve_pr_number(argument) if argument return Integer(argument[%r{/pull/(\d+)}, 1]) if argument.include?("/pull/") @@ -154,6 +240,7 @@ def print_help(manifest) Optional target ID. If omitted, choose with fzf. Options: + --wait Wait for in-progress Bitrise builds to finish, then install. -h, --help Show this help. Available targets: @@ -166,36 +253,45 @@ def print_help(manifest) dev tophat https://github.com/Shopify/checkout-kit/pull/382 Environment: - TOPHAT_DRY_RUN=1 Print the generated Tophat install config without installing. + TOPHAT_DRY_RUN=1 Print the generated Tophat install config without installing. + TOPHAT_SKIP_ARTIFACT_CHECK=1 Skip filtering targets by available Bitrise artifacts. HELP end -manifest = CheckoutKitTophat.load_manifest -if help_requested? - print_help(manifest) - exit 0 -end +def main + manifest = CheckoutKitTophat.load_manifest + if help_requested? + print_help(manifest) + exit 0 + end -CheckoutKitTophat.require_tophat! + wait_for_builds = extract_flag("--wait") -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) + CheckoutKitTophat.require_tophat! -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")}" + pr_number = resolve_pr_number(ARGV[0]) + branch = branch_for(pr_number) + options = CheckoutKitTophat.install_options(manifest) + options = filter_available_options(options, manifest, branch, pr_number, wait: wait_for_builds) unless skip_artifact_check? + option = select_option(options, 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) -if ENV["TOPHAT_DRY_RUN"] == "1" - puts JSON.pretty_generate(config) - exit 0 -end + 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")}" -Tempfile.create(["tophat-install", ".json"]) do |file| - file.write(JSON.generate(config)) - file.flush - install_with_tophat(file.path) || abort_with("Tophat install failed.") + 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 end + +main if $PROGRAM_NAME == __FILE__ From ce16948906ef56af192643a2cb2d122e476a9cc1 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 10:24:42 +0100 Subject: [PATCH 04/10] Sync Tophat token from keychain on auth failure before minting a new one --- scripts/tophat/bitrise_auth.rb | 45 ++++++++++++++++++++++------------ scripts/tophat/dev_tophat | 32 +++++++----------------- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/scripts/tophat/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb index 6b1578cb..f7c12cfe 100644 --- a/scripts/tophat/bitrise_auth.rb +++ b/scripts/tophat/bitrise_auth.rb @@ -15,27 +15,37 @@ module BitriseAuth def resolve_token(app_slug) stored = read_keychain(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) - return stored if stored && authorized?(stored, app_slug) + return stored if stored && token_status(stored, app_slug) != :unauthorized bootstrap_token(app_slug) end - def authorized?(token, app_slug) - BitriseClient.new(token: token, app_slug: app_slug).authorized? + def ensure_tophat_token(app_slug) + token = read_keychain(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + return false unless token && token_status(token, app_slug) == :ok + + sync_to_tophat(token) + true + end + + def token_status(token, app_slug) + BitriseClient.new(token: token, app_slug: app_slug).authorized? ? :ok : :unauthorized + rescue + :unknown end def bootstrap_token(app_slug) open_token_page token = prompt_until_valid(app_slug) store_token(token) - propagate_to_tophat(token) + sync_to_tophat(token) token end def open_token_page puts <<~MESSAGE - A Bitrise Personal Access Token is required to check which builds are ready. + A Bitrise Personal Access Token is required to check builds and download artifacts. Opening the Bitrise token page. Create a token, then paste it below. MESSAGE @@ -46,9 +56,16 @@ def prompt_until_valid(app_slug) loop do token = prompt_token CheckoutKitTophat.abort_with("Cancelled.") if token.nil? || token.empty? - return token if authorized?(token, app_slug) - warn "Bitrise did not accept that token. Check it was copied correctly and try again, or press Ctrl-C to cancel." + case token_status(token, app_slug) + when :ok + return token + when :unknown + warn "Could not reach Bitrise to validate the token; continuing." + return token + else + warn "Bitrise rejected that token. Check it was copied correctly and try again, or press Ctrl-C to cancel." + end end end @@ -64,21 +81,17 @@ def store_token(token) CheckoutKitTophat.abort_with("Could not store the Bitrise token in your keychain.") end - def propagate_to_tophat(token) - return unless File.exist?(TOPHAT_APP) + def sync_to_tophat(token) + return true unless File.exist?(TOPHAT_APP) - puts <<~MESSAGE - Saving the token into Tophat so installs can download build artifacts. - macOS may ask to let "security" update Tophat's keychain entry; choose Always Allow. - To skip this, add the token yourself in Tophat -> Settings -> Extensions -> Bitrise. - - MESSAGE - return if write_keychain(TOPHAT_KEYCHAIN_SERVICE, TOPHAT_KEYCHAIN_ACCOUNT, token, trusted_app: TOPHAT_APP) + system("security", "delete-generic-password", "-s", TOPHAT_KEYCHAIN_SERVICE, "-a", TOPHAT_KEYCHAIN_ACCOUNT, out: File::NULL, err: File::NULL) + return true if write_keychain(TOPHAT_KEYCHAIN_SERVICE, TOPHAT_KEYCHAIN_ACCOUNT, token, trusted_app: TOPHAT_APP) warn <<~MESSAGE Could not save the token into Tophat automatically. Add it manually in Tophat -> Settings -> Extensions -> Bitrise before installing. MESSAGE + false end def read_keychain(service, account) diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat index c7ea5060..520b26ac 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -178,22 +178,6 @@ def bitrise_auth_error?(output) 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 @@ -210,17 +194,19 @@ def run_tophat_install(config_path) [output, status] end -def install_with_tophat(config_path) +def install_with_tophat(config_path, app_slug) output, status = run_tophat_install(config_path) return true if status.success? + return false unless bitrise_auth_error?(output) - 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? + if BitriseAuth.ensure_tophat_token(app_slug) + _synced_output, synced_status = run_tophat_install(config_path) + return true if synced_status.success? end - false + BitriseAuth.bootstrap_token(app_slug) + _final_output, final_status = run_tophat_install(config_path) + final_status.success? end def help_requested? @@ -290,7 +276,7 @@ def main Tempfile.create(["tophat-install", ".json"]) do |file| file.write(JSON.generate(config)) file.flush - install_with_tophat(file.path) || abort_with("Tophat install failed.") + install_with_tophat(file.path, manifest.fetch("app_slug")) || abort_with("Tophat install failed.") end end From 49d648d75b8de5737b6bb35ea495afa030782247 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 10:36:07 +0100 Subject: [PATCH 05/10] Confirm Bitrise auth and Tophat storage, and exit cleanly on Ctrl-C --- scripts/tophat/bitrise_auth.rb | 7 ++++++- scripts/tophat/dev_tophat | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/tophat/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb index f7c12cfe..fdc1f797 100644 --- a/scripts/tophat/bitrise_auth.rb +++ b/scripts/tophat/bitrise_auth.rb @@ -59,6 +59,7 @@ def prompt_until_valid(app_slug) case token_status(token, app_slug) when :ok + puts "✅ Authentication successful." return token when :unknown warn "Could not reach Bitrise to validate the token; continuing." @@ -84,8 +85,12 @@ def store_token(token) def sync_to_tophat(token) return true unless File.exist?(TOPHAT_APP) + puts "Storing the token in Tophat for the Bitrise artifact provider." system("security", "delete-generic-password", "-s", TOPHAT_KEYCHAIN_SERVICE, "-a", TOPHAT_KEYCHAIN_ACCOUNT, out: File::NULL, err: File::NULL) - return true if write_keychain(TOPHAT_KEYCHAIN_SERVICE, TOPHAT_KEYCHAIN_ACCOUNT, token, trusted_app: TOPHAT_APP) + if write_keychain(TOPHAT_KEYCHAIN_SERVICE, TOPHAT_KEYCHAIN_ACCOUNT, token, trusted_app: TOPHAT_APP) + puts "✅ Tophat is configured with your Bitrise token." + return true + end warn <<~MESSAGE Could not save the token into Tophat automatically. diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat index 520b26ac..3194a844 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -280,4 +280,11 @@ def main end end -main if $PROGRAM_NAME == __FILE__ +if $PROGRAM_NAME == __FILE__ + begin + main + rescue Interrupt + warn "\nCancelled." + exit 130 + end +end From b2128f7be9ad5eafe614df655aee39b9b6dc88d5 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 10:42:44 +0100 Subject: [PATCH 06/10] Explain the expected macOS password prompt when storing the Tophat token --- scripts/tophat/bitrise_auth.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/tophat/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb index fdc1f797..de8b547d 100644 --- a/scripts/tophat/bitrise_auth.rb +++ b/scripts/tophat/bitrise_auth.rb @@ -85,7 +85,11 @@ def store_token(token) def sync_to_tophat(token) return true unless File.exist?(TOPHAT_APP) - puts "Storing the token in Tophat for the Bitrise artifact provider." + puts <<~MESSAGE + Storing the token in Tophat for the Bitrise artifact provider. + macOS may ask for your login password to let Tophat read this token; this is + expected, approve it (Always Allow) so the install can continue. + MESSAGE system("security", "delete-generic-password", "-s", TOPHAT_KEYCHAIN_SERVICE, "-a", TOPHAT_KEYCHAIN_ACCOUNT, out: File::NULL, err: File::NULL) if write_keychain(TOPHAT_KEYCHAIN_SERVICE, TOPHAT_KEYCHAIN_ACCOUNT, token, trusted_app: TOPHAT_APP) puts "✅ Tophat is configured with your Bitrise token." From 961227aafd0f3b0efad15fadf4f48cb3e5a5cabb Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 10:45:59 +0100 Subject: [PATCH 07/10] Narrate the automatic Tophat token repair steps --- scripts/tophat/bitrise_auth.rb | 1 + scripts/tophat/dev_tophat | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/scripts/tophat/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb index de8b547d..200525bf 100644 --- a/scripts/tophat/bitrise_auth.rb +++ b/scripts/tophat/bitrise_auth.rb @@ -24,6 +24,7 @@ def ensure_tophat_token(app_slug) token = read_keychain(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) return false unless token && token_status(token, app_slug) == :ok + puts "==> Your saved Bitrise token is authorized, but Tophat's token is missing or invalid; syncing them." sync_to_tophat(token) true end diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat index 3194a844..9127c546 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -199,12 +199,22 @@ def install_with_tophat(config_path, app_slug) return true if status.success? return false unless bitrise_auth_error?(output) + puts "" + puts "==> Tophat could not authenticate with Bitrise. dev tophat will try to fix this for you." + puts " (You can ignore Tophat's suggestion above to add a token manually.)" + if BitriseAuth.ensure_tophat_token(app_slug) + puts "==> Retrying the install with the synced token." _synced_output, synced_status = run_tophat_install(config_path) return true if synced_status.success? + puts "==> Tophat still could not authenticate after syncing; trying a fresh token." + else + puts "==> Your saved Bitrise token is missing or no longer valid." end + puts "==> Let's create a new Bitrise token." BitriseAuth.bootstrap_token(app_slug) + puts "==> Retrying the install with the new token." _final_output, final_status = run_tophat_install(config_path) final_status.success? end From 52024b37a98d57b9dd6953ede9a52aa2a5d1a187 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 10:54:31 +0100 Subject: [PATCH 08/10] Suppress the misleading first-attempt Tophat output on auth failure --- scripts/tophat/dev_tophat | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat index 9127c546..f7aa27eb 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -178,7 +178,7 @@ def bitrise_auth_error?(output) normalized.match?(/\b(401|403)\b/) end -def run_tophat_install(config_path) +def run_tophat_install(config_path, stream: true) output = +"" status = nil @@ -186,7 +186,7 @@ def run_tophat_install(config_path) stdin.close stdout_and_stderr.each do |line| output << line - print line + print line if stream end status = wait_thread.value end @@ -195,13 +195,17 @@ def run_tophat_install(config_path) end def install_with_tophat(config_path, app_slug) - output, status = run_tophat_install(config_path) - return true if status.success? - return false unless bitrise_auth_error?(output) + output, status = run_tophat_install(config_path, stream: false) + if status.success? + print output + return true + end + unless bitrise_auth_error?(output) + print output + return false + end - puts "" puts "==> Tophat could not authenticate with Bitrise. dev tophat will try to fix this for you." - puts " (You can ignore Tophat's suggestion above to add a token manually.)" if BitriseAuth.ensure_tophat_token(app_slug) puts "==> Retrying the install with the synced token." From 1aa8e3e0fdc5522a751846b0440d7c149c2771f6 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 11:01:26 +0100 Subject: [PATCH 09/10] Confirm a successful Tophat install Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- scripts/tophat/bitrise_auth.rb | 4 ++-- scripts/tophat/dev_tophat | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tophat/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb index 200525bf..8445ccbb 100644 --- a/scripts/tophat/bitrise_auth.rb +++ b/scripts/tophat/bitrise_auth.rb @@ -88,8 +88,8 @@ def sync_to_tophat(token) puts <<~MESSAGE Storing the token in Tophat for the Bitrise artifact provider. - macOS may ask for your login password to let Tophat read this token; this is - expected, approve it (Always Allow) so the install can continue. + 🔑 macOS may ask for your login password to let Tophat read this token + (Recommended) Approve with 'Always Allow' so the install can in future. MESSAGE system("security", "delete-generic-password", "-s", TOPHAT_KEYCHAIN_SERVICE, "-a", TOPHAT_KEYCHAIN_ACCOUNT, out: File::NULL, err: File::NULL) if write_keychain(TOPHAT_KEYCHAIN_SERVICE, TOPHAT_KEYCHAIN_ACCOUNT, token, trusted_app: TOPHAT_APP) diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat index f7aa27eb..7ae4345d 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -291,6 +291,7 @@ def main file.write(JSON.generate(config)) file.flush install_with_tophat(file.path, manifest.fetch("app_slug")) || abort_with("Tophat install failed.") + puts "✅ Installed #{option.fetch("label")} to #{device.fetch("name")}." end end From 81d2278aaad36a89f515b44bcced65e2960b5048 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 11:22:56 +0100 Subject: [PATCH 10/10] Add dev tophat --clear-token to remove the saved Bitrise token Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- scripts/tophat/bitrise_auth.rb | 4 ++++ scripts/tophat/dev_tophat | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/scripts/tophat/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb index 8445ccbb..0ccfe482 100644 --- a/scripts/tophat/bitrise_auth.rb +++ b/scripts/tophat/bitrise_auth.rb @@ -20,6 +20,10 @@ def resolve_token(app_slug) bootstrap_token(app_slug) end + def clear_token + system("security", "delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, out: File::NULL, err: File::NULL) + end + def ensure_tophat_token(app_slug) token = read_keychain(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) return false unless token && token_status(token, app_slug) == :ok diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat index 7ae4345d..06eda68d 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -241,6 +241,8 @@ def print_help(manifest) Options: --wait Wait for in-progress Bitrise builds to finish, then install. + --clear-token Remove dev tophat's saved Bitrise token from your keychain + (does not change Tophat's own token) and exit. -h, --help Show this help. Available targets: @@ -265,6 +267,15 @@ def main exit 0 end + if extract_flag("--clear-token") + if BitriseAuth.clear_token + puts "✅ Cleared dev tophat's saved Bitrise token from your keychain." + else + puts "No saved Bitrise token to clear." + end + exit 0 + end + wait_for_builds = extract_flag("--wait") CheckoutKitTophat.require_tophat!