ruby_everywhere 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +12 -1
  3. data/exe/rbe +12 -1
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +21 -4
  7. data/lib/everywhere/builders/android.rb +144 -30
  8. data/lib/everywhere/builders/desktop.rb +25 -17
  9. data/lib/everywhere/builders/ios.rb +244 -9
  10. data/lib/everywhere/cli.rb +2 -0
  11. data/lib/everywhere/commands/build.rb +113 -58
  12. data/lib/everywhere/commands/clean.rb +8 -3
  13. data/lib/everywhere/commands/dev.rb +85 -11
  14. data/lib/everywhere/commands/doctor.rb +138 -21
  15. data/lib/everywhere/commands/install.rb +48 -8
  16. data/lib/everywhere/commands/platform/build.rb +136 -27
  17. data/lib/everywhere/commands/platform/login.rb +16 -2
  18. data/lib/everywhere/commands/platform/runner.rb +113 -23
  19. data/lib/everywhere/commands/preview.rb +359 -0
  20. data/lib/everywhere/commands/publish.rb +20 -2
  21. data/lib/everywhere/commands/release.rb +145 -28
  22. data/lib/everywhere/commands/shell_dir.rb +2 -2
  23. data/lib/everywhere/config.rb +88 -3
  24. data/lib/everywhere/console.rb +2 -1
  25. data/lib/everywhere/engine.rb +24 -0
  26. data/lib/everywhere/framework.rb +21 -4
  27. data/lib/everywhere/ignore.rb +3 -1
  28. data/lib/everywhere/jump.rb +121 -0
  29. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  30. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  31. data/lib/everywhere/paths.rb +13 -6
  32. data/lib/everywhere/platform/client.rb +27 -6
  33. data/lib/everywhere/platform/credentials.rb +18 -8
  34. data/lib/everywhere/platform/snapshot.rb +10 -0
  35. data/lib/everywhere/receipt.rb +55 -10
  36. data/lib/everywhere/shellout.rb +19 -0
  37. data/lib/everywhere/simulator.rb +12 -1
  38. data/lib/everywhere/version.rb +1 -1
  39. data/support/mobile/android/app/build.gradle.kts +29 -1
  40. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  41. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  42. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
  43. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  44. data/support/mobile/ios/README.md +13 -6
  45. metadata +17 -1
@@ -21,8 +21,8 @@ module Everywhere
21
21
 
22
22
  option :root, default: ".", desc: "App root directory"
23
23
  option :url, default: nil, desc: "Platform base URL"
24
- option :target, default: nil, desc: "Targets (comma-separated os-arch); overrides everywhere.yml"
25
- option :channel, default: "direct", desc: "Distribution channel"
24
+ option :target, default: nil, desc: "Targets (comma-separated os-arch[:channel]); overrides everywhere.yml"
25
+ option :channel, default: "direct", desc: "Default distribution channel for targets without a :channel suffix"
26
26
  option :ruby, default: nil, desc: "Ruby version (default: build.ruby or 4.0.6)"
27
27
  option :output, default: nil, desc: "Where to save artifacts (default: <root>/dist)"
28
28
  option :wait, type: :boolean, default: true, desc: "Wait for the build and download results"
@@ -38,29 +38,26 @@ module Everywhere
38
38
 
39
39
  client = Everywhere::Platform::Client.new(base, token: creds["token"])
40
40
  framework = config.remote? ? nil : Everywhere::Framework.detect(root_path)
41
- targets = resolve_targets(target, config)
41
+ specs = resolve_targets(target, config, channel)
42
42
  ruby ||= config.build_ruby || "4.0.6"
43
43
 
44
44
  Dir.mktmpdir("every-snapshot") do |tmp|
45
45
  signed_id = snapshot_and_upload(client, root_path, tmp)
46
- manifest = Everywhere::Receipt.new(
47
- root: root_path, config: config, framework: framework, ruby: ruby,
48
- target: targets.first, channel: channel
49
- ).manifest
46
+ manifests = build_manifests(root_path, config, framework, ruby, specs)
50
47
 
51
- build = submit(client, targets, channel, signed_id, manifest)
52
- UI.success("submitted #{UI.bold(build["id"])} for #{targets.map { |t| UI.target_label(t) }.join(", ")}")
48
+ build = submit(client, specs, channel, signed_id, manifests)
49
+ UI.success("submitted #{UI.bold(build["id"])} for #{specs.map(&:display_label).join(", ")}")
53
50
  if (build_url = build["url"] || "#{client.base_url}/builds/#{build["id"]}")
54
51
  UI.substep("watch it live → #{UI.cyan(build_url)}")
55
52
  end
56
53
 
57
54
  return unless wait
58
55
 
59
- # Set expectations before the quiet part: a hosted macOS runner is not
56
+ # Set expectations before the quiet part: a hosted runner is not
60
57
  # instant, and knowing that up front is the difference between "slow"
61
58
  # and "broken".
62
- UI.substep(UI.dim("hosted macOS runners usually start within a few minutes"))
63
- final = poll_and_tail(client, build["id"])
59
+ UI.substep(UI.dim("#{runner_wait_subject(specs)} usually start within a few minutes"))
60
+ final = poll_and_tail(client, build["id"], build_url)
64
61
  report(client, final, output || File.join(root_path, "dist"), download)
65
62
  end
66
63
  rescue Everywhere::Error => e
@@ -69,6 +66,30 @@ module Everywhere
69
66
 
70
67
  private
71
68
 
69
+ # Which hosted runner OS each target builds on — not the target's own OS
70
+ # (iOS builds on macOS, Android on Linux).
71
+ RUNNER_OS_LABELS = {
72
+ "macos" => "macOS", "ios" => "macOS",
73
+ "android" => "Linux", "linux" => "Linux", "windows" => "Windows"
74
+ }.freeze
75
+
76
+ # One requested target. `token` is the literal string the user wrote
77
+ # ("ios-arm64:testflight") and is what keys the per-target manifests on the
78
+ # wire; `os_arch` is the bare form everything else (receipts, labels,
79
+ # filenames) is built from.
80
+ TargetSpec = Struct.new(:token, :os, :arch, :channel) do
81
+ def os_arch = "#{os}-#{arch}"
82
+ def label = UI.target_label(os_arch)
83
+ def display_label = channel == "direct" ? label : "#{label} (#{channel})"
84
+ end
85
+
86
+ def runner_wait_subject(specs)
87
+ kinds = specs.filter_map { |s| RUNNER_OS_LABELS[s.os] }.uniq
88
+ return "hosted build runners" if kinds.empty?
89
+
90
+ "hosted #{kinds.join("/")} runners"
91
+ end
92
+
72
93
  def snapshot_and_upload(client, root_path, tmp)
73
94
  path = File.join(tmp, "snapshot.tar.gz")
74
95
  UI.step("snapshotting source #{UI.dim("(honoring .everywhereignore)")}")
@@ -79,6 +100,7 @@ module Everywhere
79
100
  reservation = client.post("/cli/direct_uploads",
80
101
  filename: "snapshot.tar.gz", byte_size: size,
81
102
  checksum: Everywhere::Platform::Snapshot.md5_base64(path), content_type: "application/gzip")
103
+ die_unauthorized! if reservation.unauthorized?
82
104
  UI.die!("couldn't reserve upload (HTTP #{reservation.status})") unless reservation.ok?
83
105
 
84
106
  upload = reservation.body["direct_upload"] || {}
@@ -89,21 +111,49 @@ module Everywhere
89
111
  reservation.body["signed_id"]
90
112
  end
91
113
 
92
- def submit(client, targets, channel, signed_id, manifest)
114
+ # `manifests` is keyed by the literal target token; `manifest` (singular)
115
+ # stays = the first target's, which is all an older Platform reads.
116
+ def submit(client, specs, channel, signed_id, manifests)
93
117
  res = client.post("/cli/builds",
94
- targets: targets.join(","), channel: channel, snapshot_signed_id: signed_id, manifest: manifest)
118
+ targets: specs.map(&:token).join(","), channel: channel, snapshot_signed_id: signed_id,
119
+ manifest: manifests.values.first, manifests: manifests)
95
120
  return res.body if res.ok?
96
121
 
97
- if res.status == 422 && res.body["error"] == "missing_credentials"
122
+ die_unauthorized! if res.unauthorized?
123
+ render_submit_error!(res)
124
+ end
125
+
126
+ def render_submit_error!(res)
127
+ body = res.body.is_a?(Hash) ? res.body : {}
128
+
129
+ case body["error"]
130
+ when "missing_credentials"
98
131
  UI.bad("can't build yet — missing signing credentials:")
99
- Array(res.body["targets"]).each do |t|
100
- tgt = t["target"] || {}
101
- UI.step("#{UI.os_label(tgt["os"])}-#{tgt["arch"]}/#{tgt["distribution_channel"]}: add #{Array(t["missing"]).join(", ")}")
132
+ Array(body["targets"]).each do |t|
133
+ UI.step("#{server_target_label(t["target"])}: add #{Array(t["missing"]).join(", ")}")
134
+ end
135
+ UI.step("set them up → #{UI.cyan(body["manage_url"])}")
136
+ exit 1
137
+ when "invalid_targets"
138
+ UI.bad(body["message"] || "some targets can't be built as configured:")
139
+ Array(body["targets"]).each do |t|
140
+ UI.step("#{server_target_label(t["target"])}: #{Array(t["errors"]).join("; ")}")
102
141
  end
103
- UI.step("set them up → #{UI.cyan(res.body["manage_url"])}")
104
142
  exit 1
143
+ when "unsupported_targets"
144
+ UI.step("supported: #{Array(body["supported"]).join(", ")}") if body["supported"]
105
145
  end
106
- UI.die!("build submission failed (HTTP #{res.status}) #{res.body["error"]}")
146
+
147
+ UI.die!("build submission failed (HTTP #{res.status}) #{body["message"] || body["error"]}")
148
+ end
149
+
150
+ def server_target_label(target)
151
+ target ||= {}
152
+ "#{UI.os_label(target["os"])}-#{target["arch"]}/#{target["distribution_channel"]}"
153
+ end
154
+
155
+ def die_unauthorized!
156
+ UI.die!("token expired or revoked — run `every platform login`")
107
157
  end
108
158
 
109
159
  # How often we ask the Platform for new logs. A build spends most of its
@@ -114,14 +164,30 @@ module Everywhere
114
164
  ACTIVE_POLL = 2
115
165
  SPINNER_TICK = 0.2
116
166
 
117
- def poll_and_tail(client, build_id)
167
+ # The build keeps running on the Platform whether or not we can reach it,
168
+ # so a blip mid-tail must not end the wait. Give up only after this many
169
+ # polls in a row have failed.
170
+ MAX_POLL_ERRORS = 5
171
+
172
+ def poll_and_tail(client, build_id, build_url = nil)
118
173
  printed = Hash.new("")
119
174
  last = {}
120
175
  started = now
121
176
  status = UI::Status.new
177
+ errors = 0
122
178
 
123
179
  loop do
124
- res = client.get("/cli/builds/#{build_id}/logs")
180
+ begin
181
+ res = client.get("/cli/builds/#{build_id}/logs")
182
+ errors = 0
183
+ rescue Everywhere::Error => e
184
+ errors += 1
185
+ status.clear
186
+ detach!(build_id, build_url, e) if errors >= MAX_POLL_ERRORS
187
+ UI.warn("lost contact with the Platform (#{e.message}) — retrying #{errors}/#{MAX_POLL_ERRORS}")
188
+ spin(status, [], started, IDLE_POLL)
189
+ next
190
+ end
125
191
  artifacts = res.ok? ? (res.body["artifacts"] || []) : []
126
192
 
127
193
  status.clear
@@ -140,6 +206,16 @@ module Everywhere
140
206
  status&.clear
141
207
  end
142
208
 
209
+ # We stopped watching; the build didn't stop. Leave the user everything
210
+ # they need to re-attach or grab the artifacts later.
211
+ def detach!(build_id, build_url, error)
212
+ UI.bad("gave up tailing #{UI.bold(build_id)} after #{MAX_POLL_ERRORS} failed polls — #{error.message}")
213
+ UI.step("the build is still running on the Platform")
214
+ UI.step("watch it → #{UI.cyan(build_url)}") if build_url
215
+ UI.die!("re-attach with `every platform build --wait` once you're back online, " \
216
+ "or download the artifacts from the build page")
217
+ end
218
+
143
219
  # Hold the status line alive for `interval` seconds. The spinner ticks far
144
220
  # faster than we poll so the line reads as "alive" during the multi-minute
145
221
  # pre-runner wait rather than freezing between requests.
@@ -224,11 +300,44 @@ module Everywhere
224
300
  end
225
301
  end
226
302
 
227
- # everywhere.yml build.targets, or --target, or the macOS default.
228
- def resolve_targets(override, config)
229
- list = override ? override.split(",") : config.targets
230
- list = ["macos-arm64"] if list.nil? || list.empty?
231
- list.map(&:strip).reject(&:empty?)
303
+ # everywhere.yml build.targets, or --target, or the macOS default. Each
304
+ # entry is "os-arch" with an optional ":channel" suffix; --channel is the
305
+ # default for the ones that leave it off. No channel allowlist here — the
306
+ # Platform owns which channels exist and says so in its 422.
307
+ def resolve_targets(override, config, default_channel)
308
+ list = override ? override.split(",") : config.build_target_specs
309
+ list = Array(list).map { |t| t.to_s.strip }.reject(&:empty?)
310
+ list = ["macos-arm64"] if list.empty?
311
+ list.map { |token| parse_target_spec(token, default_channel) }
312
+ end
313
+
314
+ def parse_target_spec(token, default_channel)
315
+ os_arch, channel, extra = token.split(":", -1)
316
+ if !extra.nil? || os_arch.to_s.empty? || (!channel.nil? && channel.empty?)
317
+ UI.die!("invalid target #{token.inspect} — expected os-arch[:channel], e.g. ios-arm64:testflight")
318
+ end
319
+
320
+ os, arch = os_arch.split("-", 2)
321
+ TargetSpec.new(token, os, arch, channel || default_channel)
322
+ end
323
+
324
+ # One manifest per target — they differ in `target`, and may differ in
325
+ # bundle_id/name/version via `platforms:` overrides. The source digest is
326
+ # the same for all of them, so hash the tree once and thread it through.
327
+ def build_manifests(root_path, config, framework, ruby, specs)
328
+ source = nil
329
+ lockfile = nil
330
+
331
+ specs.each_with_object({}) do |spec, out|
332
+ manifest = Everywhere::Receipt.new(
333
+ root: root_path, config: config, framework: framework, ruby: ruby,
334
+ target: spec.os_arch, channel: spec.channel,
335
+ source_checksum: source, lockfile_checksum: lockfile
336
+ ).manifest
337
+ source ||= manifest.dig("source", "checksum")
338
+ lockfile ||= manifest.dig("source", "lockfile_checksum")
339
+ out[spec.token] = manifest
340
+ end
232
341
  end
233
342
 
234
343
  def label(target)
@@ -23,6 +23,14 @@ module Everywhere
23
23
  start = client.post("/cli/device_authorizations", client_name: client_name)
24
24
  UI.die!("couldn't start login — HTTP #{start.status}") unless start.ok?
25
25
 
26
+ # A captive portal or a misconfigured proxy answers 200 with HTML, which
27
+ # parses to {} — without this we'd print an empty code and poll nothing
28
+ # for ten minutes.
29
+ unless start.body["user_code"] && start.body["device_code"]
30
+ UI.die!("#{base} answered without a device code — check EVERYWHERE_PLATFORM_URL " \
31
+ "(a captive portal or proxy may be intercepting the request)")
32
+ end
33
+
26
34
  verify_url = start.body["verification_uri_complete"] || start.body["verification_uri"]
27
35
  UI.step("confirmation code: #{UI.bold(start.body["user_code"])}")
28
36
  UI.step("opening #{UI.cyan(verify_url)}")
@@ -48,9 +56,15 @@ module Everywhere
48
56
  sleep(interval)
49
57
  UI.die!("login timed out — run `every platform login` again") if monotonic > deadline
50
58
 
51
- res = client.post("/cli/device_authorizations/token", device_code: device_code)
59
+ # The user is mid-approval in a browser; a dropped connection here must
60
+ # not kill the login. Keep polling until the deadline instead.
61
+ res = begin
62
+ client.post("/cli/device_authorizations/token", device_code: device_code)
63
+ rescue Everywhere::Error
64
+ next
65
+ end
52
66
  UI.die!("login failed — #{res.body["error"] || "invalid request"}") if res.status == 400
53
- next unless res.ok? # transient 5xx/network: keep polling until the deadline
67
+ next unless res.ok? # transient 5xx: keep polling until the deadline
54
68
 
55
69
  case res.body["status"]
56
70
  when "approved" then return res.body
@@ -7,15 +7,16 @@ require "shellwords"
7
7
  require "base64"
8
8
  require "json"
9
9
  require "open3"
10
+ require "securerandom"
10
11
  require_relative "../../ui"
11
12
  require_relative "../../paths"
12
13
  require_relative "../../platform/client"
13
14
 
14
15
  module Everywhere
15
16
  module Commands
16
- # `every platform runner` — runs ON a build runner (a GitHub Actions macOS
17
- # machine, or any Mac). It's the glue between the Runner API and the
18
- # `every release` signing engine:
17
+ # `every platform runner` — runs ON a build runner: a macOS machine for the
18
+ # macos/ios targets, macOS or Linux for android. It's the glue between the
19
+ # Runner API and the `every release` signing engine:
19
20
  #
20
21
  # job spec → mark running → download+extract snapshot → pull JIT creds into
21
22
  # a throwaway keychain (+ .p8) → every release (streaming logs) → upload the
@@ -24,6 +25,28 @@ module Everywhere
24
25
  # Authenticated with the per-artifact runner token, so it can only touch the
25
26
  # one artifact it was dispatched for.
26
27
  class PlatformRunner < Dry::CLI::Command
28
+ # What `every release` leaves in dist/ per os, newest first when several
29
+ # match, and how the blob store should label it.
30
+ ARTIFACTS = { "macos" => %w[*.zip], "ios" => %w[*.ipa], "android" => %w[*.aab *.apk] }.freeze
31
+ CONTENT_TYPES = {
32
+ ".zip" => "application/zip",
33
+ ".ipa" => "application/octet-stream",
34
+ ".apk" => "application/vnd.android.package-archive",
35
+ ".aab" => "application/octet-stream"
36
+ }.freeze
37
+ RELEASE_NARRATION = {
38
+ "macos" => "every release — press → sign → notarize → staple",
39
+ "ios" => "every release — build → archive → export .ipa",
40
+ "android" => "every release — assemble → sign → bundle"
41
+ }.freeze
42
+
43
+ # A .mobileprovision has to be in both: Xcode reads the UserData copy,
44
+ # xcodebuild's own resolution still reads the MobileDevice one.
45
+ PROFILE_DIRS = [
46
+ "~/Library/MobileDevice/Provisioning Profiles",
47
+ "~/Library/Developer/Xcode/UserData/Provisioning Profiles"
48
+ ].freeze
49
+
27
50
  desc "Run a Platform build on this machine (used by the hosted runner)"
28
51
 
29
52
  option :artifact, required: true, desc: "Artifact public id (art_…)"
@@ -49,10 +72,10 @@ module Everywhere
49
72
  src = fetch_snapshot(work, job)
50
73
  signing_env = pull_credentials(work)
51
74
 
52
- status, release_json, zip = run_release(src, job, signing_env, shell_dir)
75
+ status, release_json, built = run_release(src, job, signing_env, shell_dir)
53
76
 
54
- if status == "succeeded" && zip
55
- complete_success(zip, release_json)
77
+ if status == "succeeded" && built
78
+ complete_success(built, release_json)
56
79
  else
57
80
  complete_failure("build failed — see log")
58
81
  end
@@ -62,6 +85,12 @@ module Everywhere
62
85
  rescue Everywhere::Error => e
63
86
  complete_failure(e.message)
64
87
  UI.die!(e.message)
88
+ rescue Everywhere::Fatal => e
89
+ # UI.die! raises Fatal (a SystemExit), which the rescue above can't see —
90
+ # without this an upload/signing failure would leave the artifact stuck
91
+ # "running" on the Platform forever.
92
+ complete_failure(e.message.to_s.empty? ? "build failed" : e.message)
93
+ raise
65
94
  end
66
95
 
67
96
  private
@@ -86,18 +115,64 @@ module Everywhere
86
115
  path = File.join(work, cred["filename"])
87
116
  File.binwrite(path, Base64.strict_decode64(cred["data_base64"]))
88
117
  File.chmod(0o600, path)
118
+ meta = cred["metadata"] || {}
89
119
  case cred["kind"]
90
120
  when "apple_developer_id", "apple_distribution"
91
- env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, cred["metadata"] || {}, work)
121
+ env["EVERY_IOS_TEAM_ID"] ||= meta["team_id"]
122
+ # An android job runs on Linux, where there's no keychain to import
123
+ # into. A stray apple credential there is the Platform's mistake, not
124
+ # a reason to lose the build.
125
+ if darwin?
126
+ env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, meta, work)
127
+ else
128
+ remote_log("skipping #{cred["kind"]} — keychain import needs macOS")
129
+ end
92
130
  when "apple_asc_api_key"
93
131
  env["NOTARY_KEY"] = path
94
- env["NOTARY_KEY_ID"] = cred.dig("metadata", "key_id")
95
- env["NOTARY_ISSUER"] = cred.dig("metadata", "issuer_id")
132
+ env["NOTARY_KEY_ID"] = meta["key_id"]
133
+ env["NOTARY_ISSUER"] = meta["issuer_id"]
134
+ when "apple_provisioning_profile"
135
+ profile = install_provisioning_profile(path)
136
+ env["EVERY_IOS_PROVISIONING_PROFILE"] = profile[:name]
137
+ env["EVERY_IOS_TEAM_ID"] ||= profile[:team]
138
+ when "android_keystore"
139
+ env["EVERY_ANDROID_KEYSTORE"] = path
140
+ env["EVERY_ANDROID_KEYSTORE_PASSWORD"] = meta["store_password"]
141
+ env["EVERY_ANDROID_KEY_ALIAS"] = meta["key_alias"]
142
+ # One-key keystores are usually created with a single password.
143
+ env["EVERY_ANDROID_KEY_PASSWORD"] = meta["key_password"] || meta["store_password"]
144
+ end
145
+ end
146
+ env.compact
147
+ end
148
+
149
+ # A .mobileprovision is a CMS-signed DER blob wrapped around a plain XML
150
+ # plist. Scanning for the plist reads it identically on Linux, where
151
+ # `security cms -D` doesn't exist.
152
+ def install_provisioning_profile(path)
153
+ plist = File.binread(path)[/<\?xml.*<\/plist>/m]
154
+ UI.die!("the delivered provisioning profile has no embedded plist") unless plist
155
+
156
+ uuid = plist[%r{<key>UUID</key>\s*<string>([^<]+)</string>}m, 1]
157
+ name = plist[%r{<key>Name</key>\s*<string>([^<]+)</string>}m, 1]
158
+ # TeamIdentifier is an <array> of one — the first <string> after the key.
159
+ team = plist[%r{<key>TeamIdentifier</key>.*?<string>([^<]+)</string>}m, 1]
160
+ UI.die!("the delivered provisioning profile has no UUID") unless uuid
161
+
162
+ if darwin?
163
+ profile_dirs.each do |dir|
164
+ FileUtils.mkdir_p(dir)
165
+ FileUtils.cp(path, File.join(dir, "#{uuid}.mobileprovision"))
96
166
  end
97
167
  end
98
- env
168
+ remote_log("provisioning profile #{UI.bold(name || uuid)}#{team ? " #{UI.dim("(team #{team})")}" : ""}")
169
+ { name: name, team: team, uuid: uuid }
99
170
  end
100
171
 
172
+ def profile_dirs = PROFILE_DIRS.map { |dir| File.expand_path(dir) }
173
+
174
+ def darwin? = RUBY_PLATFORM.include?("darwin")
175
+
101
176
  # Import the .p12 into a throwaway keychain and add it to the search list so
102
177
  # codesign finds the identity. Returns the identity name for notarize.sh.
103
178
  def install_certificate(p12, metadata, work)
@@ -124,11 +199,16 @@ module Everywhere
124
199
  end
125
200
 
126
201
  def run_release(src, job, signing_env, shell_dir)
127
- remote_log("every release — press → sign → notarize → staple")
202
+ os = job.dig("target", "os").to_s
203
+ channel = job.dig("target", "distribution_channel").to_s
204
+ remote_log(RELEASE_NARRATION.fetch(os, "every release"))
128
205
  cmd = [RbConfig.ruby, "-I", lib_dir, every_exe, "release",
129
206
  "--root", src,
130
- "--target", "#{job.dig("target", "os")}-#{job.dig("target", "arch")}",
131
- "--channel", job.dig("target", "distribution_channel").to_s]
207
+ "--target", "#{os}-#{job.dig("target", "arch")}",
208
+ "--channel", channel]
209
+ # Play wants an App Bundle; every other Android channel (direct download,
210
+ # sideload, Amazon) wants the installable apk `every release` defaults to.
211
+ cmd += ["--format", "aab"] if os == "android" && channel == "play"
132
212
  cmd += ["--shell-dir", shell_dir] if shell_dir
133
213
 
134
214
  # The web UI renders ANSI, but `every release` runs here as a pipe (not a
@@ -144,37 +224,45 @@ module Everywhere
144
224
 
145
225
  dist = File.join(src, "dist")
146
226
  release_json = JSON.parse(File.read(File.join(dist, "release.json"))) rescue nil
147
- zip = Dir[File.join(dist, "*.zip")].max_by { |f| File.mtime(f) }
148
- [success ? "succeeded" : "failed", release_json, zip]
227
+ built = ARTIFACTS.fetch(os, %w[*.zip])
228
+ .filter_map { |p| Dir[File.join(dist, p)].max_by { |f| File.mtime(f) } }.first
229
+ [success ? "succeeded" : "failed", release_json, built]
149
230
  end
150
231
 
151
- def complete_success(zip, release_json)
232
+ def complete_success(built, release_json)
152
233
  report_phase("uploading")
153
- remote_log("uploading artifact #{UI.dim("(#{(File.size(zip) / 1024.0 / 1024.0).round(1)}MB)")}")
154
- signed_id = upload_artifact(zip)
234
+ remote_log("uploading artifact #{UI.dim("(#{(File.size(built) / 1024.0 / 1024.0).round(1)}MB)")}")
235
+ signed_id = upload_artifact(built)
155
236
  post!("/runner/artifacts/#{@artifact}/complete",
156
237
  status: "succeeded", file_signed_id: signed_id, release_json: release_json)
157
- UI.success("build succeeded — #{File.basename(zip)}")
238
+ UI.success("build succeeded — #{File.basename(built)}")
158
239
  end
159
240
 
241
+ # Best-effort: if the thing that failed WAS connectivity, reporting the
242
+ # failure fails too — raising here would replace the real error and abort
243
+ # cleanup, so log locally and move on.
160
244
  def complete_failure(message)
161
245
  return if @completed
162
246
 
163
247
  @client.post("/runner/artifacts/#{@artifact}/complete", status: "failed", error: message)
164
248
  @completed = true
249
+ rescue Everywhere::Error => e
250
+ UI.warn("couldn't report the failure to the Platform (#{e.message})")
165
251
  end
166
252
 
167
- def upload_artifact(zip)
253
+ def upload_artifact(built)
168
254
  require_relative "../../platform/snapshot"
169
255
  reservation = post!("/runner/artifacts/#{@artifact}/upload",
170
- filename: File.basename(zip), byte_size: File.size(zip),
171
- checksum: Everywhere::Platform::Snapshot.md5_base64(zip), content_type: "application/zip")
256
+ filename: File.basename(built), byte_size: File.size(built),
257
+ checksum: Everywhere::Platform::Snapshot.md5_base64(built), content_type: content_type_for(built))
172
258
  du = reservation["direct_upload"] || {}
173
- res = @client.put_file(du["url"], zip, headers: du["headers"] || {})
259
+ res = @client.put_file(du["url"], built, headers: du["headers"] || {})
174
260
  UI.die!("artifact upload failed (HTTP #{res.code})") unless res.code.to_i.between?(200, 299)
175
261
  reservation["signed_id"]
176
262
  end
177
263
 
264
+ def content_type_for(path) = CONTENT_TYPES.fetch(File.extname(path), "application/octet-stream")
265
+
178
266
  # ---- log streaming -------------------------------------------------------
179
267
 
180
268
  # Run cmd, echoing output to this process's stdout AND forwarding it to the
@@ -241,6 +329,8 @@ module Everywhere
241
329
  end
242
330
 
243
331
  def restore_keychains
332
+ return unless darwin?
333
+
244
334
  run_security("list-keychains", "-d", "user", "-s", *@saved_keychains) if @saved_keychains
245
335
  run_security("delete-keychain", @keychain) if @keychain && File.exist?(@keychain)
246
336
  end