Skip to content
Draft
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
36 changes: 23 additions & 13 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
20 changes: 18 additions & 2 deletions scripts/tophat/bitrise_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
104 changes: 104 additions & 0 deletions scripts/tophat/build_state.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading