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/bitrise_auth.rb b/scripts/tophat/bitrise_auth.rb new file mode 100644 index 00000000..0ccfe482 --- /dev/null +++ b/scripts/tophat/bitrise_auth.rb @@ -0,0 +1,124 @@ +# 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 && token_status(stored, app_slug) != :unauthorized + + 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 + + puts "==> Your saved Bitrise token is authorized, but Tophat's token is missing or invalid; syncing them." + 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) + sync_to_tophat(token) + token + end + + def open_token_page + puts <<~MESSAGE + + 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 + 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? + + 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." + 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 + + 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 sync_to_tophat(token) + return true unless File.exist?(TOPHAT_APP) + + 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 + (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) + puts "✅ Tophat is configured with your Bitrise token." + return true + end + + 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) + 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 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 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..06eda68d 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/") @@ -92,23 +178,7 @@ 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) +def run_tophat_install(config_path, stream: true) output = +"" status = nil @@ -116,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 @@ -124,17 +194,33 @@ def run_tophat_install(config_path) [output, status] end -def install_with_tophat(config_path) - output, status = run_tophat_install(config_path) - return true if status.success? +def install_with_tophat(config_path, app_slug) + 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 "==> Tophat could not authenticate with Bitrise. dev tophat will try to fix this for you." - 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) + 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 - false + 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 def help_requested? @@ -154,6 +240,9 @@ def print_help(manifest) Optional target ID. If omitted, choose with fzf. 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: @@ -166,36 +255,62 @@ 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 + + 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 -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 + 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, manifest.fetch("app_slug")) || abort_with("Tophat install failed.") + puts "✅ Installed #{option.fetch("label")} to #{device.fetch("name")}." + end 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.") +if $PROGRAM_NAME == __FILE__ + begin + main + rescue Interrupt + warn "\nCancelled." + exit 130 + end end