From 2570056b8a77042941c5998a474d8ff5deacbe2f Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 10 Jul 2026 12:03:13 +0100 Subject: [PATCH] feat: add the ability to wait for a build to complete --- .github/CONTRIBUTING.md | 36 ++-- scripts/tophat/bitrise_client.rb | 20 ++- scripts/tophat/build_state.rb | 104 ++++++++++++ scripts/tophat/dev_tophat | 280 +++++++++++++++++++++++++------ 4 files changed, 371 insertions(+), 69 deletions(-) create mode 100644 scripts/tophat/build_state.rb diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4cf82a18..2d76c486 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -88,11 +88,11 @@ 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 +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 +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 @@ -102,21 +102,31 @@ There are three ways to install a build: 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 382 --wait # ensure the HEAD build, then install dev tophat https://github.com/Shopify/checkout-kit/pull/382 ``` - 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. + It resolves the PR's HEAD commit, asks what to test (e.g. React Native iOS / + Android), then checks that the selected target's newest Bitrise build was + built at that HEAD commit. Tophat's branch provider always installs the + newest build for the branch, so this guards against silently installing an + older commit's artifact. When the newest build already matches HEAD it + installs straight away. Otherwise it shows the current CI state and offers to: + - trigger a HEAD build and wait (~6 min), or, when a HEAD build is already + running, wait for that one instead of starting a duplicate; + - install the current (older) build, showing its short SHA, commit title, + how many commits it is behind HEAD, and its age; or + - cancel. + + Draft PRs do not automatically trigger CI, so on a draft with no builds it + offers to trigger the build directly. Pass `--wait` to skip the menu and + ensure the HEAD build non-interactively (wait for a running HEAD build, or + trigger one and wait). After the build is ready it reuses a running device + that matches or lets you pick one with `fzf`, and installs the 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. + installing, or `TOPHAT_SKIP_ARTIFACT_CHECK=1` to skip the HEAD-build + verification and install whatever the branch provider resolves. **Adding a new SDK target.** Add an entry to `scripts/tophat/targets.json` with an `id`, `label`, and `recipes` (each a `platform`, `destination`, Bitrise @@ -285,7 +295,7 @@ If your change intentionally modifies the public API: 2. Review the diff in `platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md` alongside your code changes. 3. Commit the updated `.api.md` file in the same PR. -If you did *not* intend to change public API and `api:check` is failing, the diff shows what your change inadvertently affected — treat it as a signal that something in your PR has consumer-visible impact. +If you did _not_ intend to change public API and `api:check` is failing, the diff shows what your change inadvertently affected — treat it as a signal that something in your PR has consumer-visible impact. ### Releasing a new React Native version diff --git a/scripts/tophat/bitrise_client.rb b/scripts/tophat/bitrise_client.rb index e57c96d4..eaa8573c 100644 --- a/scripts/tophat/bitrise_client.rb +++ b/scripts/tophat/bitrise_client.rb @@ -22,8 +22,24 @@ def authorized? end def latest_build(branch:, workflow:) - query = URI.encode_www_form(branch: branch, workflow: workflow, limit: 1) - @transport.get("#{API_BASE}/apps/#{@app_slug}/builds?#{query}").fetch("data", []).first + builds(branch: branch, workflow: workflow, limit: 1).first + end + + def builds(branch:, workflow:, limit: 20) + query = URI.encode_www_form(branch: branch, workflow: workflow, limit: limit) + @transport.get("#{API_BASE}/apps/#{@app_slug}/builds?#{query}").fetch("data", []) + end + + def build(build_slug) + @transport.get("#{API_BASE}/apps/#{@app_slug}/builds/#{build_slug}").fetch("data") + end + + def trigger_build(branch:, commit_hash:, workflow:) + body = { + hook_info: {type: "bitrise"}, + build_params: {branch: branch, commit_hash: commit_hash, workflow_id: workflow} + } + @transport.post_json("#{API_BASE}/apps/#{@app_slug}/builds", body) end def artifact_titles(build_slug) diff --git a/scripts/tophat/build_state.rb b/scripts/tophat/build_state.rb new file mode 100644 index 00000000..53a1bf2e --- /dev/null +++ b/scripts/tophat/build_state.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "time" + +# Pure classification of a target's Bitrise CI state relative to a PR's HEAD +# commit. Callers fetch the build data and artifact readiness over the network, +# then hand plain data here to decide what the install flow should offer. +module TophatBuildState + module_function + + RUNNING_STATUS = 0 + SUCCESS_STATUS = 1 + + Evaluation = Struct.new(:workflow, :head, :head_ready, :current, :installable, keyword_init: true) + + def head_build(builds, head_sha) + builds.find { |build| build["commit_hash"] == head_sha } + end + + def workflow_state(evaluation) + head = evaluation.head + if head + return :head_ready if evaluation.head_ready + return :head_running if head.fetch("status") == RUNNING_STATUS + + return :failed + end + + current = evaluation.current + return :none unless current + return :stale_running if current.fetch("status") == RUNNING_STATUS + return :stale_ready if evaluation.installable + + :failed + end + + def combine(evaluations) + states = evaluations.map { |evaluation| workflow_state(evaluation) } + return :head_ready if states.all? { |state| state == :head_ready } + return :head_running if states.include?(:head_running) + return :failed if states.include?(:failed) + return :stale_ready if states.all? { |state| state == :stale_ready } + return :stale_running if states.include?(:stale_running) + + :none + end + + def installable_available?(evaluations) + evaluations.all? { |evaluation| !evaluation.installable.nil? } + end + + def running_head_builds(evaluations) + evaluations + .map(&:head) + .compact + .select { |build| build.fetch("status") == RUNNING_STATUS } + .uniq { |build| build.fetch("slug") } + end + + def menu_choices(state:, installable_available:) + choices = [state == :head_running ? :wait_head : :trigger_head] + choices << :install_current if installable_available + choices << :cancel + choices + end + + def describe_build(build, now:, behind_by: nil) + parts = [short_sha(build.fetch("commit_hash"))] + title = commit_title(build["commit_message"]) + parts << %("#{title}") if title + parts << "#{behind_by} behind" if behind_by && behind_by.positive? + age = relative_age(build, now: now) + parts << age if age + parts.join(" · ") + end + + def short_sha(commit_hash) + commit_hash.to_s[0, 7] + end + + def commit_title(commit_message) + return nil unless commit_message + + title = commit_message.to_s.lines.first.to_s.strip + return nil if title.empty? + + title.length > 60 ? "#{title[0, 57]}..." : title + end + + def relative_age(build, now:) + stamp = build["finished_at"] || build["triggered_at"] + return nil unless stamp + + seconds = (now - Time.parse(stamp)).to_i + return nil if seconds.negative? + return "#{seconds / 86_400}d ago" if seconds >= 86_400 + return "#{seconds / 3_600}h ago" if seconds >= 3_600 + return "#{seconds / 60}m ago" if seconds >= 60 + + "just now" + rescue ArgumentError + nil + end +end diff --git a/scripts/tophat/dev_tophat b/scripts/tophat/dev_tophat index 06eda68d..08d1345c 100755 --- a/scripts/tophat/dev_tophat +++ b/scripts/tophat/dev_tophat @@ -5,6 +5,7 @@ require "tempfile" require_relative "common" require_relative "bitrise_client" require_relative "bitrise_auth" +require_relative "build_state" WAIT_POLL_SECONDS = 30 WAIT_TIMEOUT_SECONDS = 1800 @@ -39,71 +40,235 @@ 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 +def pr_metadata(pr_number) + data = JSON.parse(capture("gh", "pr", "view", pr_number.to_s, "--json", "headRefName,headRefOid,isDraft")) + { + "branch" => data.fetch("headRefName"), + "sha" => data.fetch("headRefOid"), + "draft" => data.fetch("isDraft") + } +end + +def commits_behind_head(build_sha, head_sha) + return nil if build_sha.nil? || build_sha.empty? || build_sha == head_sha + + stdout, status = Open3.capture2("gh", "api", "repos/{owner}/{repo}/compare/#{build_sha}...#{head_sha}", "--jq", ".ahead_by", err: File::NULL) + return nil unless status.success? + + Integer(stdout.strip) +rescue ArgumentError + nil +end + +def artifacts_ready?(client, build, artifact_names) + return false unless build.fetch("status") == TophatBuildState::SUCCESS_STATUS titles = client.artifact_titles(build.fetch("slug")) - artifact_names.all? { |name| titles.include?(name) } ? :ready : :missing + artifact_names.all? { |name| titles.include?(name) } +end + +def latest_installable(client, builds, artifact_names) + builds.find { |build| artifacts_ready?(client, build, artifact_names) } +end + +def evaluate_option(client, option, pr) + recipes_for_option(option).group_by { |recipe| recipe.fetch("workflow") }.map do |workflow, recipes| + artifact_names = recipes.map { |recipe| recipe.fetch("artifact_name") } + builds = client.builds(branch: pr.fetch("branch"), workflow: workflow, limit: 20) + head = TophatBuildState.head_build(builds, pr.fetch("sha")) + head_ready = head ? artifacts_ready?(client, head, artifact_names) : false + TophatBuildState::Evaluation.new( + workflow: workflow, + head: head, + head_ready: head_ready, + current: builds.first, + installable: head_ready ? head : latest_installable(client, builds, artifact_names) + ) + end +end + +def installable_build(evaluations) + evaluations.map(&:installable).compact.first +end + +def installable_summary(evaluations, pr) + build = installable_build(evaluations) + behind = commits_behind_head(build.fetch("commit_hash"), pr.fetch("sha")) + TophatBuildState.describe_build(build, now: Time.now, behind_by: behind) 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 +def announce_state(state, evaluations, pr) + case state + when :head_running + puts "🟡 A HEAD build is already in progress." + when :stale_ready + puts "🟡 Newest build is BEHIND HEAD — built #{installable_summary(evaluations, pr)}" + when :stale_running + puts "🟡 A build is in progress, but for an older commit than HEAD." + when :failed + puts "🔴 The most recent build failed, so there is no HEAD artifact." + when :none + if pr.fetch("draft") + puts "🟡 No Bitrise builds found. Draft PRs do not automatically trigger CI." + else + puts "🟡 No Bitrise builds found for this branch yet." + end + end 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") }) +def menu_label(choice, evaluations, pr) + case choice + when :wait_head + running = evaluations.map(&:head).compact.find { |build| build.fetch("status") == TophatBuildState::RUNNING_STATUS } + number = running && running["build_number"] + number ? "Wait for the in-progress build ##{number}" : "Wait for the in-progress HEAD build" + when :trigger_head + "Trigger HEAD build + wait (~6 min)" + when :install_current + "Install the last successful build now (#{installable_summary(evaluations, pr)})" + when :cancel + "Cancel" end - combine_states(states) end -def evaluate_options(client, branch, options) - options.map { |option| {option: option, state: option_state(client, branch, option)} } +def choose_action(option, state, evaluations, pr, wait:) + choices = TophatBuildState.menu_choices( + state: state, + installable_available: TophatBuildState.installable_available?(evaluations) + ) + return choices.first if wait + + lines = choices.each_with_index.map { |choice, index| "#{index}\t#{menu_label(choice, evaluations, pr)}" } + selection = fzf_select(lines, prompt: "#{option.fetch("label")} not built at HEAD — choose") + choices.fetch(Integer(selection.split("\t", 2).first)) +end + +def bitrise_build_url(slug) + "https://app.bitrise.io/build/#{slug}" 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 +def format_elapsed(seconds) + minutes, secs = seconds.divmod(60) + minutes.positive? ? "#{minutes}m#{secs.to_s.rjust(2, "0")}s" : "#{secs}s" +end - puts "==> Waiting for Bitrise builds to finish: #{running.map { |entry| entry.fetch(:option).fetch("label") }.join(", ")}" +def trigger_head_builds(client, option, pr) + workflows = recipes_for_option(option).map { |recipe| recipe.fetch("workflow") }.uniq + workflows.map do |workflow| + response = client.trigger_build(branch: pr.fetch("branch"), commit_hash: pr.fetch("sha"), workflow: workflow) + slug = response.fetch("build_slug") + number = response["build_number"] + puts "==> Triggered #{number ? "build ##{number}" : "build"} for #{TophatBuildState.short_sha(pr.fetch("sha"))} (#{workflow})" + puts " #{response["build_url"] || bitrise_build_url(slug)}" + slug + end +end + +def wait_for_builds(client, slugs) + started = Time.now + deadline = started + WAIT_TIMEOUT_SECONDS + pending = slugs.dup + waiting_shown = false + until pending.empty? + builds = pending.map { |slug| [slug, client.build(slug)] } + builds.each do |slug, build| + status = build.fetch("status") + next if status == TophatBuildState::RUNNING_STATUS + + unless status == TophatBuildState::SUCCESS_STATUS + puts if waiting_shown && $stdout.tty? + abort_with("The Bitrise build finished unsuccessfully (status #{status}). Check the build in Bitrise and re-run it.") + end + pending.delete(slug) + end + break if pending.empty? + if Time.now >= deadline + puts if waiting_shown && $stdout.tty? + abort_with("Timed out after #{WAIT_TIMEOUT_SECONDS / 60} minutes waiting for the Bitrise build.") + end + + print_waiting(started, builds.select { |slug, _| pending.include?(slug) }.map(&:last)) + waiting_shown = true sleep WAIT_POLL_SECONDS end + puts if waiting_shown && $stdout.tty? + puts "==> Build finished ✓" +end + +def build_phase(build) + return "On hold (waiting for a runner)" if build["is_on_hold"] + return "Queued" unless build["started_on_worker_at"] + + machine = build["machine_type_id"] + queued = queued_seconds(build) + suffix = machine ? " on #{machine}" : "" + queued ? "Running#{suffix} (queued #{format_elapsed(queued)})" : "Running#{suffix}" +end + +def queued_seconds(build) + started = build["started_on_worker_at"] + triggered = build["triggered_at"] + return nil unless started && triggered + + (Time.parse(started) - Time.parse(triggered)).to_i +rescue ArgumentError + nil 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." +def print_waiting(started, builds) + elapsed = format_elapsed((Time.now - started).to_i) + descriptor = + if builds.length == 1 + "Build ##{builds.first["build_number"]} · #{build_phase(builds.first)}" else - "The build workflow(s) failed, so no artifact was produced. Check the pipeline in Bitrise and re-run it." + "#{builds.length} builds · #{builds.map { |build| build_phase(build) }.uniq.join(", ")}" end - "No installable build artifacts for PR ##{pr_number} (#{branch}).\n#{guidance}" + message = "==> #{descriptor} · #{elapsed} elapsed" + if $stdout.tty? + print "\r#{message} " + $stdout.flush + else + puts message + end end -def filter_available_options(options, manifest, branch, pr_number, wait:) +def reverify_head!(client, option, pr) + state = TophatBuildState.combine(evaluate_option(client, option, pr)) + return if state == :head_ready + + abort_with("The build finished but produced no HEAD artifact for #{option.fetch("label")}. Check the Bitrise build logs.") +end + +def ensure_installable(option, manifest, pr, 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? + evaluations = evaluate_option(client, option, pr) + state = TophatBuildState.combine(evaluations) - abort_with(unavailable_message(evaluated, pr_number, branch)) -rescue => error - warn "Could not verify Bitrise artifacts (#{error.message}). Showing all targets." - options + puts "==> Checking Bitrise: #{option.fetch("label")} @ HEAD #{TophatBuildState.short_sha(pr.fetch("sha"))}…" + if state == :head_ready + puts "🟢 HEAD build is ready." + return + end + + announce_state(state, evaluations, pr) + case choose_action(option, state, evaluations, pr, wait: wait) + when :wait_head + running = TophatBuildState.running_head_builds(evaluations) + running.each { |build| puts "==> Following build ##{build["build_number"]}: #{bitrise_build_url(build.fetch("slug"))}" } + wait_for_builds(client, running.map { |build| build.fetch("slug") }) + reverify_head!(client, option, pr) + when :trigger_head + wait_for_builds(client, trigger_head_builds(client, option, pr)) + reverify_head!(client, option, pr) + when :install_current + puts "==> Installing the last successful build via Tophat's branch provider." + when :cancel + puts "Cancelled." + exit 0 + end +rescue RuntimeError => error + abort_with("Could not verify Bitrise artifacts: #{error.message}\nRe-run once Bitrise is reachable, or set TOPHAT_SKIP_ARTIFACT_CHECK=1 to bypass this check.") end def resolve_pr_number(argument) @@ -113,9 +278,10 @@ def resolve_pr_number(argument) return Integer(argument) end - prs = JSON.parse(capture("gh", "pr", "list", "--json", "number,title,author,headRefName", "--limit", "50")) + prs = JSON.parse(capture("gh", "pr", "list", "--json", "number,title,author,headRefName,updatedAt", "--limit", "50")) abort_with("No open pull requests found.") if prs.empty? + prs.sort_by! { |pr| pr.fetch("updatedAt") }.reverse! lines = prs.map do |pr| number = pr.fetch("number") display = "##{number} #{pr.fetch("title")} @#{pr.dig("author", "login")} #{pr.fetch("headRefName")}" @@ -125,10 +291,6 @@ def resolve_pr_number(argument) 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 } @@ -234,13 +396,20 @@ def print_help(manifest) Install a pull request build to a device via Tophat. + After you pick a target, dev tophat checks that its newest Bitrise build was + built at the PR's HEAD commit. If it was not, it shows what CI currently has + and lets you trigger a HEAD build and wait (~6 min), wait for a HEAD build + that is already running, or knowingly install the current (older) build. + 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: - --wait Wait for in-progress Bitrise builds to finish, then install. + --wait Non-interactively ensure the HEAD build: wait for a HEAD + build that is already running, or trigger one and wait, + then install. Skips the menu. --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. @@ -252,11 +421,13 @@ def print_help(manifest) dev tophat dev tophat 382 dev tophat 382 react-native-ios + dev tophat --wait 382 dev tophat https://github.com/Shopify/checkout-kit/pull/382 Environment: TOPHAT_DRY_RUN=1 Print the generated Tophat install config without installing. - TOPHAT_SKIP_ARTIFACT_CHECK=1 Skip filtering targets by available Bitrise artifacts. + TOPHAT_SKIP_ARTIFACT_CHECK=1 Skip the HEAD-build verification and install whatever + the branch provider resolves. HELP end @@ -276,20 +447,21 @@ def main exit 0 end - wait_for_builds = extract_flag("--wait") + wait_flag = extract_flag("--wait") CheckoutKitTophat.require_tophat! pr_number = resolve_pr_number(ARGV[0]) - branch = branch_for(pr_number) + pr = pr_metadata(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]) + ensure_installable(option, manifest, pr, wait: wait_flag) unless skip_artifact_check? + device = select_device(option.fetch("platform")) recipe = recipe_for(option.fetch("target"), device) - config = CheckoutKitTophat.install_config(recipe, device, manifest.fetch("app_slug"), branch) + config = CheckoutKitTophat.install_config(recipe, device, manifest.fetch("app_slug"), pr.fetch("branch")) - puts "==> Installing #{option.fetch("label")} from PR ##{pr_number} (#{branch})" + puts "==> Installing #{option.fetch("label")} from PR ##{pr_number} (#{pr.fetch("branch")})" puts " Device: #{device.fetch("name")} #{device.fetch("runtimeVersion")} (#{device.fetch("type")})" puts " Artifact: #{recipe.fetch("artifact_name")}"