ruby_native 0.15.3 → 0.17.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.
- checksums.yaml +4 -4
- data/config/signals.yml +134 -0
- data/lib/ruby_native/cli/check/signal_collector.rb +30 -0
- data/lib/ruby_native/cli/check.rb +254 -0
- data/lib/ruby_native/cli/deploy.rb +149 -64
- data/lib/ruby_native/cli.rb +11 -3
- data/lib/ruby_native/helper.rb +7 -0
- data/lib/ruby_native/signals.rb +78 -0
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +10 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a22cd9f35d5b96e79ccc9f09dbeeaf7bc06a6e3802bd7312ba9ce2b01d33487
|
|
4
|
+
data.tar.gz: 60c54403a9e8cc7dcda9b5236b73f393bedd76eb154466d54e764a31f9d7e692
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5cd0687aca82369bf29b5923ef2ce5cf88bc2ee033763e30d9629e957a5616dedcf9a12456a85d97a5d5c56f3a65f5dd26759e4ce616a0e1914b59cc90e1c1ca
|
|
7
|
+
data.tar.gz: 4053dcb73e348fe54cde1a0b60cc29bab4b73e7359458dafa4adb03c8da85cbaf66940443ed12b23402c8981c94f6074ac448fe17e0b8aaa2186b8586f16b93c
|
data/config/signals.yml
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# The signal vocabulary: every data-native-* attribute Ruby Native accepts.
|
|
2
|
+
#
|
|
3
|
+
# `ruby_native check` validates your views against this file, and the native
|
|
4
|
+
# shells assert their own vocabulary matches it. `since` is the gem version
|
|
5
|
+
# that introduced the signal; a signal older than the shell running it is
|
|
6
|
+
# ignored silently on device, which is what `check` exists to catch.
|
|
7
|
+
#
|
|
8
|
+
# `owner: shell` marks a signal the app writes into the page rather than one
|
|
9
|
+
# your views emit.
|
|
10
|
+
---
|
|
11
|
+
signals:
|
|
12
|
+
data-native-action:
|
|
13
|
+
since: "0.11.2"
|
|
14
|
+
data-native-anchor:
|
|
15
|
+
since: "0.14.0"
|
|
16
|
+
helper: native_menu_tag
|
|
17
|
+
data-native-app:
|
|
18
|
+
owner: shell
|
|
19
|
+
data-native-badge:
|
|
20
|
+
since: "0.3.2"
|
|
21
|
+
helper: native_badge_tag
|
|
22
|
+
singleton: true
|
|
23
|
+
data-native-badge-home:
|
|
24
|
+
since: "0.3.2"
|
|
25
|
+
helper: native_badge_tag
|
|
26
|
+
data-native-badge-tab:
|
|
27
|
+
since: "0.3.2"
|
|
28
|
+
helper: native_badge_tag
|
|
29
|
+
data-native-button:
|
|
30
|
+
since: "0.4.0"
|
|
31
|
+
data-native-click:
|
|
32
|
+
since: "0.4.0"
|
|
33
|
+
helper: native_fab_tag
|
|
34
|
+
data-native-color:
|
|
35
|
+
since: "0.14.0"
|
|
36
|
+
helper: native_fab_tag
|
|
37
|
+
data-native-destructive:
|
|
38
|
+
since: "0.14.0"
|
|
39
|
+
data-native-fab:
|
|
40
|
+
since: "0.8.0"
|
|
41
|
+
helper: native_fab_tag
|
|
42
|
+
singleton: true
|
|
43
|
+
data-native-form:
|
|
44
|
+
since: "0.1.2"
|
|
45
|
+
helper: native_form_tag
|
|
46
|
+
singleton: true
|
|
47
|
+
data-native-haptic:
|
|
48
|
+
since: "0.2.7"
|
|
49
|
+
helper: native_haptic_data
|
|
50
|
+
data-native-href:
|
|
51
|
+
since: "0.4.0"
|
|
52
|
+
helper: native_fab_tag
|
|
53
|
+
data-native-icon:
|
|
54
|
+
since: "0.4.0"
|
|
55
|
+
helper: native_fab_tag
|
|
56
|
+
data-native-identity:
|
|
57
|
+
since: "0.12.0"
|
|
58
|
+
helper: native_identity_tag
|
|
59
|
+
singleton: true
|
|
60
|
+
data-native-keyboard-toolbar:
|
|
61
|
+
since: "0.15.0"
|
|
62
|
+
helper: native_keyboard_tag
|
|
63
|
+
singleton: true
|
|
64
|
+
data-native-menu:
|
|
65
|
+
since: "0.14.0"
|
|
66
|
+
helper: native_menu_tag
|
|
67
|
+
data-native-menu-item:
|
|
68
|
+
since: "0.4.0"
|
|
69
|
+
data-native-navbar:
|
|
70
|
+
since: "0.4.0"
|
|
71
|
+
helper: native_navbar_tag
|
|
72
|
+
singleton: true
|
|
73
|
+
data-native-overscroll-bottom:
|
|
74
|
+
since: "0.5.0"
|
|
75
|
+
helper: native_overscroll_tag
|
|
76
|
+
singleton: true
|
|
77
|
+
data-native-overscroll-top:
|
|
78
|
+
since: "0.5.0"
|
|
79
|
+
helper: native_overscroll_tag
|
|
80
|
+
singleton: true
|
|
81
|
+
data-native-position:
|
|
82
|
+
since: "0.4.0"
|
|
83
|
+
data-native-presentation:
|
|
84
|
+
since: "0.11.2"
|
|
85
|
+
helper: native_presentation_tag
|
|
86
|
+
singleton: true
|
|
87
|
+
data-native-price:
|
|
88
|
+
since: "0.5.3"
|
|
89
|
+
data-native-pull-to-refresh:
|
|
90
|
+
since: "0.10.0"
|
|
91
|
+
helper: native_navbar_tag
|
|
92
|
+
data-native-purchase:
|
|
93
|
+
since: "0.5.3"
|
|
94
|
+
data-native-push:
|
|
95
|
+
since: "0.1.2"
|
|
96
|
+
helper: native_push_tag
|
|
97
|
+
singleton: true
|
|
98
|
+
data-native-restore:
|
|
99
|
+
since: "0.5.3"
|
|
100
|
+
data-native-review:
|
|
101
|
+
since: "0.9.3"
|
|
102
|
+
helper: native_review_tag
|
|
103
|
+
data-native-segment:
|
|
104
|
+
since: "0.10.12"
|
|
105
|
+
data-native-selected:
|
|
106
|
+
since: "0.4.0"
|
|
107
|
+
data-native-share:
|
|
108
|
+
since: "0.10.9"
|
|
109
|
+
data-native-share-url:
|
|
110
|
+
since: "0.10.9"
|
|
111
|
+
data-native-submit-button:
|
|
112
|
+
since: "0.4.0"
|
|
113
|
+
singleton: true
|
|
114
|
+
data-native-tabs:
|
|
115
|
+
since: "0.1.2"
|
|
116
|
+
helper: native_tabs_tag
|
|
117
|
+
singleton: true
|
|
118
|
+
data-native-title:
|
|
119
|
+
since: "0.4.0"
|
|
120
|
+
data-native-toast:
|
|
121
|
+
since: "0.12.1"
|
|
122
|
+
helper: native_toast_tag
|
|
123
|
+
data-native-toast-appearance:
|
|
124
|
+
since: "0.12.1"
|
|
125
|
+
helper: native_toast_tag
|
|
126
|
+
data-native-toast-duration:
|
|
127
|
+
since: "0.12.1"
|
|
128
|
+
helper: native_toast_tag
|
|
129
|
+
data-native-toast-icon:
|
|
130
|
+
since: "0.12.1"
|
|
131
|
+
helper: native_toast_tag
|
|
132
|
+
data-native-toast-message:
|
|
133
|
+
since: "0.12.1"
|
|
134
|
+
helper: native_toast_tag
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RubyNative
|
|
2
|
+
class CLI
|
|
3
|
+
class Check
|
|
4
|
+
# Walks a parsed template and records every data-native-* attribute with
|
|
5
|
+
# the lines it appears on. Attribute names built out of ERB cannot be
|
|
6
|
+
# resolved statically and are skipped rather than guessed at.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses Herb::Visitor, so this file is only loaded once `herb` is
|
|
9
|
+
# known to be present.
|
|
10
|
+
class SignalCollector < Herb::Visitor
|
|
11
|
+
attr_reader :signals
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@signals = {}
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def visit_html_attribute_name_node(node)
|
|
19
|
+
name = node.children.filter_map { |child| child.content if child.respond_to?(:content) }.join
|
|
20
|
+
|
|
21
|
+
if name.start_with?("data-native-")
|
|
22
|
+
(@signals[name] ||= []) << node.location.start.line
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "ruby_native/cli/credentials"
|
|
6
|
+
require "ruby_native/signals"
|
|
7
|
+
require "ruby_native/version"
|
|
8
|
+
|
|
9
|
+
module RubyNative
|
|
10
|
+
class CLI
|
|
11
|
+
# Static validation of a Rails app's views against the signal vocabulary.
|
|
12
|
+
#
|
|
13
|
+
# Signals fail silently by design: a shell that does not understand one
|
|
14
|
+
# ignores it. That is right for progressive enhancement and miserable for
|
|
15
|
+
# debugging, so this moves the diagnostics the bridge already runs per page
|
|
16
|
+
# visit to a whole-app pass that can fail CI before a build goes out.
|
|
17
|
+
class Check
|
|
18
|
+
CONFIG_PATH = "config/ruby_native.yml"
|
|
19
|
+
HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
|
|
20
|
+
DEFAULT_PATHS = ["app/views"].freeze
|
|
21
|
+
PLATFORMS = %w[ios android].freeze
|
|
22
|
+
|
|
23
|
+
Offense = Struct.new(:file, :line, :severity, :message, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# The same checks `run` reports, for `deploy` to run as a preflight.
|
|
26
|
+
# Returns nil when herb is missing rather than failing: a deploy must not
|
|
27
|
+
# start depending on a gem the app never asked for.
|
|
28
|
+
def self.signal_offenses(paths: DEFAULT_PATHS)
|
|
29
|
+
return nil unless herb_available?
|
|
30
|
+
|
|
31
|
+
check = new([], paths: paths)
|
|
32
|
+
|
|
33
|
+
check.send(:template_files).flat_map { |file| check.send(:check_file, file) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.herb_available?
|
|
37
|
+
require "herb"
|
|
38
|
+
require "ruby_native/cli/check/signal_collector"
|
|
39
|
+
true
|
|
40
|
+
rescue LoadError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(argv, paths: nil)
|
|
45
|
+
@deployed = argv.include?("--deployed")
|
|
46
|
+
@paths = paths || parse_paths(argv)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run
|
|
50
|
+
require_herb!
|
|
51
|
+
|
|
52
|
+
files = template_files
|
|
53
|
+
if files.empty?
|
|
54
|
+
puts "No .html.erb templates found in #{@paths.join(", ")}."
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
offenses = files.flat_map { |file| check_file(file) }
|
|
59
|
+
offenses += deployed_offenses(files) if @deployed
|
|
60
|
+
|
|
61
|
+
report(files, offenses)
|
|
62
|
+
exit 1 if offenses.any? { |offense| offense.severity == :error }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# --- Loading ---
|
|
68
|
+
|
|
69
|
+
def require_herb!
|
|
70
|
+
return if self.class.herb_available?
|
|
71
|
+
|
|
72
|
+
puts "`ruby_native check` needs the herb gem, which parses HTML and ERB together."
|
|
73
|
+
puts ""
|
|
74
|
+
puts "Add it to your Gemfile:"
|
|
75
|
+
puts " gem \"herb\""
|
|
76
|
+
puts ""
|
|
77
|
+
puts "Rails 8.2 and later already ship it as a dependency of Action View."
|
|
78
|
+
exit 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def template_files
|
|
82
|
+
@paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.html.erb")) }.sort
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- Per-file checks ---
|
|
86
|
+
|
|
87
|
+
# Herb's parser tolerates markup its compiler rejects, so every template
|
|
88
|
+
# gets scanned whether or not it would build under Rails 8.2. Whether
|
|
89
|
+
# their ERB is Herb-clean is their upgrade to make, and `herb lint` is
|
|
90
|
+
# the tool for it.
|
|
91
|
+
def check_file(file)
|
|
92
|
+
signal_offenses(file, File.read(file, encoding: "UTF-8"))
|
|
93
|
+
rescue ArgumentError => error
|
|
94
|
+
[Offense.new(file: file, line: 1, severity: :error, message: "Could not read the file: #{error.message}")]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def signal_offenses(file, source)
|
|
98
|
+
result = Herb.parse(source)
|
|
99
|
+
collector = SignalCollector.new
|
|
100
|
+
result.value.accept(collector)
|
|
101
|
+
|
|
102
|
+
offenses = []
|
|
103
|
+
|
|
104
|
+
collector.signals.each do |name, lines|
|
|
105
|
+
unless RubyNative::Signals.known?(name)
|
|
106
|
+
suggestion = RubyNative::Signals.nearest(name)
|
|
107
|
+
message = "Unknown signal `#{name}`."
|
|
108
|
+
message += " Did you mean `#{suggestion}`?" if suggestion
|
|
109
|
+
offenses << Offense.new(file: file, line: lines.first, severity: :error, message: message)
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if lines.size > 1 && RubyNative::Signals.singleton?(name)
|
|
114
|
+
offenses << Offense.new(
|
|
115
|
+
file: file,
|
|
116
|
+
line: lines[1],
|
|
117
|
+
severity: :warning,
|
|
118
|
+
message: "#{lines.size} elements carry `#{name}`; only the first one is used."
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
offenses.concat(version_offenses(file, name, lines.first))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
offenses
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A signal newer than the installed gem is one the app was never told
|
|
129
|
+
# about, so it is inert on device however correct it looks in the markup.
|
|
130
|
+
def version_offenses(file, name, line)
|
|
131
|
+
since = RubyNative::Signals.since(name)
|
|
132
|
+
return [] unless since
|
|
133
|
+
return [] if Gem::Version.new(RubyNative::VERSION) >= Gem::Version.new(since)
|
|
134
|
+
|
|
135
|
+
[Offense.new(
|
|
136
|
+
file: file,
|
|
137
|
+
line: line,
|
|
138
|
+
severity: :error,
|
|
139
|
+
message: "`#{name}` needs Ruby Native #{since}, but this app is on #{RubyNative::VERSION}."
|
|
140
|
+
)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- Deployed build check ---
|
|
144
|
+
|
|
145
|
+
# The gem version in the Gemfile says what the views may emit. The build
|
|
146
|
+
# in the store says what the app can actually honor, and those drift
|
|
147
|
+
# apart the moment someone bumps the gem without deploying.
|
|
148
|
+
def deployed_offenses(files)
|
|
149
|
+
app_id = config.dig(:ruby_native, :app_id)
|
|
150
|
+
unless app_id
|
|
151
|
+
puts "Skipping --deployed: no app_id in #{CONFIG_PATH}. Run `ruby_native deploy` once to link this app."
|
|
152
|
+
return []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
unless Credentials.token
|
|
156
|
+
puts "Skipping --deployed: not logged in. Run `ruby_native login` first."
|
|
157
|
+
return []
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
used = signals_in(files)
|
|
161
|
+
PLATFORMS.flat_map { |platform| deployed_offenses_for(platform, app_id, used) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def deployed_offenses_for(platform, app_id, used)
|
|
165
|
+
built = latest_build_version(app_id, platform)
|
|
166
|
+
return [] unless built
|
|
167
|
+
|
|
168
|
+
used.filter_map do |name, (file, line)|
|
|
169
|
+
since = RubyNative::Signals.since(name)
|
|
170
|
+
next unless since
|
|
171
|
+
next if Gem::Version.new(built) >= Gem::Version.new(since)
|
|
172
|
+
|
|
173
|
+
Offense.new(
|
|
174
|
+
file: file,
|
|
175
|
+
line: line,
|
|
176
|
+
severity: :error,
|
|
177
|
+
message: "`#{name}` needs #{since}, but the #{platform} build users have was made on #{built}."
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def signals_in(files)
|
|
183
|
+
files.each_with_object({}) do |file, used|
|
|
184
|
+
collector = SignalCollector.new
|
|
185
|
+
Herb.parse(File.read(file, encoding: "UTF-8")).value.accept(collector)
|
|
186
|
+
collector.signals.each { |name, lines| used[name] ||= [file, lines.first] }
|
|
187
|
+
rescue StandardError
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def latest_build_version(app_id, platform)
|
|
193
|
+
uri = URI("#{HOST}/api/v1/apps/#{app_id}/builds/latest?platform=#{platform}")
|
|
194
|
+
request = Net::HTTP::Get.new(uri)
|
|
195
|
+
request["Authorization"] = "Token #{Credentials.token}"
|
|
196
|
+
|
|
197
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
198
|
+
http.request(request)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
202
|
+
return nil if response.body.to_s.empty?
|
|
203
|
+
|
|
204
|
+
JSON.parse(response.body)["gem_version"]
|
|
205
|
+
rescue StandardError
|
|
206
|
+
# A check that cannot reach the API should not fail the build; the
|
|
207
|
+
# local checks above still ran and still mean something.
|
|
208
|
+
puts "Skipping --deployed for #{platform}: could not reach #{HOST}."
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def config
|
|
213
|
+
@config ||= if File.exist?(CONFIG_PATH)
|
|
214
|
+
YAML.load_file(CONFIG_PATH, symbolize_names: true, aliases: true) || {}
|
|
215
|
+
else
|
|
216
|
+
{}
|
|
217
|
+
end
|
|
218
|
+
rescue Psych::SyntaxError
|
|
219
|
+
{}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# --- Reporting ---
|
|
223
|
+
|
|
224
|
+
def report(files, offenses)
|
|
225
|
+
errors, warnings = offenses.partition { |offense| offense.severity == :error }
|
|
226
|
+
|
|
227
|
+
offenses.group_by(&:file).sort.each do |file, file_offenses|
|
|
228
|
+
puts file
|
|
229
|
+
file_offenses.sort_by(&:line).each do |offense|
|
|
230
|
+
location = "#{offense.line}:".ljust(5)
|
|
231
|
+
marker = offense.severity.to_s.ljust(8)
|
|
232
|
+
puts " #{location}#{marker}#{offense.message}"
|
|
233
|
+
end
|
|
234
|
+
puts ""
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
summary = "Checked #{files.size} #{files.size == 1 ? "template" : "templates"}"
|
|
238
|
+
if offenses.empty?
|
|
239
|
+
puts "#{summary}. No problems found."
|
|
240
|
+
else
|
|
241
|
+
puts "#{summary}: #{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
|
|
242
|
+
"#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def parse_paths(argv)
|
|
247
|
+
flag = argv.find { |argument| argument.start_with?("--paths=") }
|
|
248
|
+
return DEFAULT_PATHS unless flag
|
|
249
|
+
|
|
250
|
+
flag.split("=", 2).last.split(",").map(&:strip).reject(&:empty?)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -2,6 +2,7 @@ require "json"
|
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "uri"
|
|
4
4
|
require "openssl"
|
|
5
|
+
require "ruby_native/cli/check"
|
|
5
6
|
require "ruby_native/cli/credentials"
|
|
6
7
|
require "ruby_native/version"
|
|
7
8
|
|
|
@@ -12,7 +13,7 @@ module RubyNative
|
|
|
12
13
|
HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
|
|
13
14
|
POLL_INTERVAL = 5
|
|
14
15
|
POLL_TIMEOUT = 600
|
|
15
|
-
PLATFORMS = %w[ios android].freeze
|
|
16
|
+
PLATFORMS = %w[ios android all].freeze
|
|
16
17
|
# Consecutive failures tolerated mid-poll; one blip must not kill a
|
|
17
18
|
# deploy whose build is succeeding server-side.
|
|
18
19
|
MAX_POLL_FAILURES = 3
|
|
@@ -31,6 +32,7 @@ module RubyNative
|
|
|
31
32
|
|
|
32
33
|
def initialize(argv)
|
|
33
34
|
@if_needed = argv.include?("--if-needed")
|
|
35
|
+
@skip_check = argv.include?("--skip-check")
|
|
34
36
|
@platform = parse_platform(argv)
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -44,10 +46,14 @@ module RubyNative
|
|
|
44
46
|
return
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
check_signals! unless @skip_check
|
|
50
|
+
|
|
47
51
|
build = trigger_build(app_id)
|
|
48
52
|
return if @if_needed
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
# `builds` is present when one deploy started more than one; an older
|
|
55
|
+
# server omits it and the primary build is the whole story.
|
|
56
|
+
poll_build_status(app_id, build, Array(build["builds"]).drop(1))
|
|
51
57
|
rescue TokenExpiredError
|
|
52
58
|
abort_token_expired!
|
|
53
59
|
rescue ConnectionError => error
|
|
@@ -58,6 +64,25 @@ module RubyNative
|
|
|
58
64
|
|
|
59
65
|
private
|
|
60
66
|
|
|
67
|
+
# A broken signal is invisible until someone opens the build, so it is
|
|
68
|
+
# worth a second here rather than a TestFlight round trip. Skipped
|
|
69
|
+
# silently when herb is missing: this is a courtesy, not a dependency the
|
|
70
|
+
# gem gets to impose on a deploy.
|
|
71
|
+
def check_signals!
|
|
72
|
+
offenses = Check.signal_offenses
|
|
73
|
+
return if offenses.nil?
|
|
74
|
+
|
|
75
|
+
errors = offenses.select { |offense| offense.severity == :error }
|
|
76
|
+
return if errors.empty?
|
|
77
|
+
|
|
78
|
+
puts "Found #{errors.size} signal #{errors.size == 1 ? "problem" : "problems"} in your views:"
|
|
79
|
+
errors.first(10).each { |error| puts " #{error.file}:#{error.line} #{error.message}" }
|
|
80
|
+
puts " ...and #{errors.size - 10} more." if errors.size > 10
|
|
81
|
+
puts
|
|
82
|
+
puts "These will not work in the build. Fix them, or deploy anyway with --skip-check."
|
|
83
|
+
exit 1
|
|
84
|
+
end
|
|
85
|
+
|
|
61
86
|
def ensure_authenticated!
|
|
62
87
|
return if Credentials.token
|
|
63
88
|
|
|
@@ -135,14 +160,30 @@ module RubyNative
|
|
|
135
160
|
latest = fetch_latest_build(app_id)
|
|
136
161
|
return false unless latest
|
|
137
162
|
|
|
138
|
-
|
|
139
|
-
return false unless latest_gem_version
|
|
163
|
+
return skip_all?(latest) if @platform == "all"
|
|
140
164
|
|
|
141
|
-
|
|
165
|
+
current?(latest["gem_version"])
|
|
142
166
|
rescue ArgumentError
|
|
143
167
|
false
|
|
144
168
|
end
|
|
145
169
|
|
|
170
|
+
# Skip only when every platform that could build is already on this gem
|
|
171
|
+
# version. A track that is set up but has never built is exactly the case
|
|
172
|
+
# that must not skip, which is why the server reports `deployable`
|
|
173
|
+
# separately from whether a build exists.
|
|
174
|
+
def skip_all?(latest)
|
|
175
|
+
deployable = latest.select { |_platform, state| state.is_a?(Hash) && state["deployable"] }
|
|
176
|
+
return false if deployable.empty?
|
|
177
|
+
|
|
178
|
+
deployable.all? { |_platform, state| current?(state["gem_version"]) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def current?(gem_version)
|
|
182
|
+
return false unless gem_version
|
|
183
|
+
|
|
184
|
+
Gem::Version.new(gem_version) >= Gem::Version.new(RubyNative::VERSION)
|
|
185
|
+
end
|
|
186
|
+
|
|
146
187
|
def fetch_latest_build(app_id)
|
|
147
188
|
# Platform-scoped, or --android --if-needed compares against the
|
|
148
189
|
# latest iOS build and skips a build Android never got.
|
|
@@ -218,17 +259,21 @@ module RubyNative
|
|
|
218
259
|
|
|
219
260
|
# --- Polling ---
|
|
220
261
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
262
|
+
# Polls every build this deploy started, not just the first. With both
|
|
263
|
+
# platforms dispatched at once, watching only one meant a failed Android
|
|
264
|
+
# build exited 0 and left CI green over a broken release.
|
|
265
|
+
def poll_build_status(app_id, build, others = [])
|
|
266
|
+
states = ([build] + Array(others)).compact.uniq { |candidate| candidate["id"] }.map do |candidate|
|
|
267
|
+
{ id: candidate["id"], platform: candidate["platform"], status: candidate["status"], not_found: 0, error: nil, result: nil }
|
|
268
|
+
end
|
|
269
|
+
many = states.size > 1
|
|
270
|
+
|
|
224
271
|
puts ""
|
|
225
|
-
puts "Waiting for build to complete. Ctrl+C to exit (your build will continue)."
|
|
226
|
-
print_status(
|
|
272
|
+
puts "Waiting for #{many ? "builds" : "build"} to complete. Ctrl+C to exit (your #{many ? "builds" : "build"} will continue)."
|
|
273
|
+
states.each { |state| print_status(state[:status], label_for(state, many)) }
|
|
227
274
|
|
|
228
275
|
started_at = Time.now
|
|
229
276
|
connection_failures = 0
|
|
230
|
-
not_found_count = 0
|
|
231
|
-
reported_error = nil
|
|
232
277
|
|
|
233
278
|
loop do
|
|
234
279
|
sleep POLL_INTERVAL
|
|
@@ -239,70 +284,106 @@ module RubyNative
|
|
|
239
284
|
exit 1
|
|
240
285
|
end
|
|
241
286
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
287
|
+
connection_error = nil
|
|
288
|
+
reached = 0
|
|
289
|
+
|
|
290
|
+
states.reject { |state| state[:result] }.each do |state|
|
|
291
|
+
begin
|
|
292
|
+
api_state, payload = fetch_build_status(app_id, state[:id])
|
|
293
|
+
reached += 1
|
|
294
|
+
rescue ConnectionError => error
|
|
295
|
+
connection_error = error
|
|
296
|
+
next
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
advance_build(state, api_state, payload, many)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Only a tick where nothing was reachable counts against the budget,
|
|
303
|
+
# so one platform answering keeps the deploy alive.
|
|
304
|
+
if reached.zero?
|
|
246
305
|
connection_failures += 1
|
|
247
306
|
next if connection_failures < MAX_POLL_FAILURES
|
|
248
307
|
|
|
249
308
|
puts ""
|
|
250
|
-
puts
|
|
309
|
+
puts connection_error.message
|
|
251
310
|
puts "Your build is most likely still running server-side. Check the"
|
|
252
311
|
puts "Ruby Native dashboard for the result: #{HOST}/dashboard"
|
|
253
312
|
exit 1
|
|
254
313
|
end
|
|
255
314
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
next if not_found_count < MAX_POLL_FAILURES
|
|
315
|
+
connection_failures = 0
|
|
316
|
+
break if states.all? { |state| state[:result] }
|
|
317
|
+
end
|
|
260
318
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if payload != reported_error
|
|
269
|
-
reported_error = payload
|
|
270
|
-
puts ""
|
|
271
|
-
puts "#{URI(HOST).host} returned #{payload} while checking the build. Still trying..."
|
|
272
|
-
end
|
|
273
|
-
next
|
|
274
|
-
end
|
|
319
|
+
exit 1 if states.any? { |state| state[:result] == :failed }
|
|
320
|
+
rescue Interrupt
|
|
321
|
+
puts ""
|
|
322
|
+
puts ""
|
|
323
|
+
puts "Stopped polling. Your build is still running."
|
|
324
|
+
puts "Check the Ruby Native dashboard for status."
|
|
325
|
+
end
|
|
275
326
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
print_notice(data)
|
|
327
|
+
def advance_build(state, api_state, payload, many)
|
|
328
|
+
label = label_for(state, many)
|
|
279
329
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
330
|
+
case api_state
|
|
331
|
+
when :not_found
|
|
332
|
+
state[:not_found] += 1
|
|
333
|
+
return if state[:not_found] < MAX_POLL_FAILURES
|
|
284
334
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
335
|
+
puts ""
|
|
336
|
+
puts "#{label}The server can no longer find this build (404). The app or build"
|
|
337
|
+
puts "may have been deleted or archived. Check the Ruby Native"
|
|
338
|
+
puts "dashboard: #{HOST}/dashboard"
|
|
339
|
+
state[:result] = :failed
|
|
340
|
+
return
|
|
341
|
+
when :error
|
|
342
|
+
# The build keeps running server-side through a 500, so keep polling.
|
|
343
|
+
if payload != state[:error]
|
|
344
|
+
state[:error] = payload
|
|
295
345
|
puts ""
|
|
296
|
-
puts "
|
|
297
|
-
puts " Error: #{data["error_message"]}" if data["error_message"]
|
|
298
|
-
exit 1
|
|
346
|
+
puts "#{label}#{URI(HOST).host} returned #{payload} while checking the build. Still trying..."
|
|
299
347
|
end
|
|
348
|
+
return
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
state[:not_found] = 0
|
|
352
|
+
data = payload
|
|
353
|
+
print_notice(data)
|
|
354
|
+
|
|
355
|
+
if data["status"] != state[:status]
|
|
356
|
+
state[:status] = data["status"]
|
|
357
|
+
print_status(state[:status], label)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
case state[:status]
|
|
361
|
+
when "success", "ready"
|
|
362
|
+
puts ""
|
|
363
|
+
puts "#{label}Build succeeded!"
|
|
364
|
+
puts " Version: v#{data["version"]} (#{data["number"]})"
|
|
365
|
+
puts " Ruby Native: #{data["native_version"]}" if data["native_version"]
|
|
366
|
+
puts ""
|
|
367
|
+
puts success_destination_message(data)
|
|
368
|
+
state[:result] = :succeeded
|
|
369
|
+
when "failure", "failed", "cancelled"
|
|
370
|
+
puts ""
|
|
371
|
+
puts "#{label}Build failed."
|
|
372
|
+
puts " Error: #{data["error_message"]}" if data["error_message"]
|
|
373
|
+
state[:result] = :failed
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Platform names only appear when there is more than one build to tell
|
|
378
|
+
# apart, so single-platform output is unchanged.
|
|
379
|
+
def label_for(state, many)
|
|
380
|
+
return "" unless many
|
|
381
|
+
|
|
382
|
+
case state[:platform]
|
|
383
|
+
when "ios" then "iOS: "
|
|
384
|
+
when "android" then "Android: "
|
|
385
|
+
else ""
|
|
300
386
|
end
|
|
301
|
-
rescue Interrupt
|
|
302
|
-
puts ""
|
|
303
|
-
puts ""
|
|
304
|
-
puts "Stopped polling. Your build is still running."
|
|
305
|
-
puts "Check the Ruby Native dashboard for status."
|
|
306
387
|
end
|
|
307
388
|
|
|
308
389
|
# Returns [:ok, data], [:not_found, nil], or [:error, description].
|
|
@@ -331,9 +412,9 @@ module RubyNative
|
|
|
331
412
|
end
|
|
332
413
|
end
|
|
333
414
|
|
|
334
|
-
def print_status(status)
|
|
415
|
+
def print_status(status, prefix = "")
|
|
335
416
|
label = status_labels[status]
|
|
336
|
-
puts " #{label}..." if label
|
|
417
|
+
puts " #{prefix}#{label}..." if label
|
|
337
418
|
end
|
|
338
419
|
|
|
339
420
|
# Server-provided warnings (a lapsed payment, say) reach customers with
|
|
@@ -373,15 +454,19 @@ module RubyNative
|
|
|
373
454
|
end
|
|
374
455
|
end
|
|
375
456
|
|
|
457
|
+
# Every configured platform by default: "deploy my app" means the app, not
|
|
458
|
+
# whichever half was written first. The server decides what is actually
|
|
459
|
+
# ready, so an iOS-only app still just builds iOS.
|
|
376
460
|
def parse_platform(argv)
|
|
377
461
|
return "android" if argv.include?("--android")
|
|
462
|
+
return "ios" if argv.include?("--ios")
|
|
378
463
|
|
|
379
464
|
flag = argv.find { |a| a.start_with?("--platform=") }
|
|
380
|
-
return "
|
|
465
|
+
return "all" unless flag
|
|
381
466
|
|
|
382
467
|
value = flag.split("=", 2).last
|
|
383
468
|
unless PLATFORMS.include?(value)
|
|
384
|
-
puts "Unknown platform #{value.inspect}. Use --platform=ios or --platform=
|
|
469
|
+
puts "Unknown platform #{value.inspect}. Use --platform=ios, --platform=android, or --platform=all."
|
|
385
470
|
exit 1
|
|
386
471
|
end
|
|
387
472
|
value
|
data/lib/ruby_native/cli.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "ruby_native/cli/check"
|
|
1
2
|
require "ruby_native/cli/credentials"
|
|
2
3
|
require "ruby_native/cli/deploy"
|
|
3
4
|
require "ruby_native/cli/login"
|
|
@@ -8,6 +9,8 @@ module RubyNative
|
|
|
8
9
|
def self.start(argv)
|
|
9
10
|
command = argv.shift
|
|
10
11
|
case command
|
|
12
|
+
when "check"
|
|
13
|
+
RubyNative::CLI::Check.new(argv).run
|
|
11
14
|
when "deploy"
|
|
12
15
|
RubyNative::CLI::Deploy.new(argv).run
|
|
13
16
|
when "preview"
|
|
@@ -40,10 +43,15 @@ module RubyNative
|
|
|
40
43
|
puts "Usage: ruby_native <command> [options]"
|
|
41
44
|
puts ""
|
|
42
45
|
puts "Commands:"
|
|
43
|
-
puts "
|
|
44
|
-
puts " --
|
|
45
|
-
puts " --
|
|
46
|
+
puts " check Validate your views against the signal vocabulary"
|
|
47
|
+
puts " --deployed Also compare against the build your users have"
|
|
48
|
+
puts " --paths=A,B Directories to scan (default: app/views)"
|
|
49
|
+
puts " deploy Build every configured platform and wait for it to finish"
|
|
50
|
+
puts " --ios Build iOS only"
|
|
51
|
+
puts " --android Build Android only"
|
|
52
|
+
puts " --platform=NAME Build for ios, android, or all"
|
|
46
53
|
puts " --if-needed Skip when this gem version is already built"
|
|
54
|
+
puts " --skip-check Deploy without checking your views for broken signals"
|
|
47
55
|
puts " login Authenticate with Ruby Native"
|
|
48
56
|
puts " logout Remove stored credentials"
|
|
49
57
|
puts " preview Start a tunnel and display a QR code"
|
data/lib/ruby_native/helper.rb
CHANGED
|
@@ -347,6 +347,13 @@ module RubyNative
|
|
|
347
347
|
@items = []
|
|
348
348
|
end
|
|
349
349
|
|
|
350
|
+
# Adds a button to the nav bar. `position:` is :leading, :trailing (the
|
|
351
|
+
# default; repeat it and the buttons sit side by side), or :title, which
|
|
352
|
+
# turns the bar's title into a dropdown menu instead of adding a bar item.
|
|
353
|
+
# A :title button takes a menu block and no icon or label of its own: keep
|
|
354
|
+
# a title on `native_navbar_tag` and it labels the dropdown, or omit it and
|
|
355
|
+
# the `selected:` item's text does.
|
|
356
|
+
# https://rubynative.com/docs/navbar#title-menu
|
|
350
357
|
def button(title = nil, icon: nil, icons: nil, href: nil, click: nil, position: :trailing, selected: false, &block)
|
|
351
358
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
352
359
|
data = { native_button: "" }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module RubyNative
|
|
4
|
+
# The signal vocabulary, loaded from config/signals.yml. This is the single
|
|
5
|
+
# source of truth for what a valid data-native-* attribute is: `ruby_native
|
|
6
|
+
# check` validates views against it, and the native shells assert their own
|
|
7
|
+
# copy matches.
|
|
8
|
+
module Signals
|
|
9
|
+
PATH = File.expand_path("../../config/signals.yml", __dir__)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def all
|
|
13
|
+
@all ||= YAML.load_file(PATH).fetch("signals").freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def names
|
|
17
|
+
@names ||= all.keys.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def known?(name)
|
|
21
|
+
all.key?(name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Signals read via querySelector, where only the first match wins, so a
|
|
25
|
+
# second one on the same page is silently ignored.
|
|
26
|
+
def singletons
|
|
27
|
+
@singletons ||= all.select { |_name, meta| meta["singleton"] }.keys.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def singleton?(name)
|
|
31
|
+
singletons.include?(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The gem version that introduced the signal. nil for signals the app
|
|
35
|
+
# writes into the page rather than ones your views emit.
|
|
36
|
+
def since(name)
|
|
37
|
+
all.dig(name, "since")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def helper(name)
|
|
41
|
+
all.dig(name, "helper")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Closest known signal within one or two edits, for "did you mean". A
|
|
45
|
+
# genuinely unrelated attribute gets no suggestion rather than a
|
|
46
|
+
# confusing one.
|
|
47
|
+
def nearest(name, threshold: 2)
|
|
48
|
+
# Ties on edit distance break toward the longer shared prefix, so a
|
|
49
|
+
# typo'd `data-native-tab` suggests `data-native-tabs` rather than
|
|
50
|
+
# `data-native-fab`, which is just as close by raw distance.
|
|
51
|
+
candidate = names.min_by { |known| [distance(name, known), -common_prefix_length(name, known)] }
|
|
52
|
+
candidate if candidate && distance(name, candidate) <= threshold
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def common_prefix_length(a, b)
|
|
58
|
+
length = 0
|
|
59
|
+
length += 1 while length < a.length && length < b.length && a[length] == b[length]
|
|
60
|
+
length
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def distance(a, b)
|
|
64
|
+
rows = Array.new(a.length + 1) { |i| [i] + Array.new(b.length, 0) }
|
|
65
|
+
(0..b.length).each { |j| rows[0][j] = j }
|
|
66
|
+
|
|
67
|
+
(1..a.length).each do |i|
|
|
68
|
+
(1..b.length).each do |j|
|
|
69
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
70
|
+
rows[i][j] = [rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost].min
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
rows[a.length][b.length]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/ruby_native/version.rb
CHANGED
data/lib/ruby_native.rb
CHANGED
|
@@ -63,6 +63,7 @@ module RubyNative
|
|
|
63
63
|
backfill_tab_icons
|
|
64
64
|
backfill_error_icons
|
|
65
65
|
warn_on_duplicate_tab_keys
|
|
66
|
+
normalize_analytics
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
# config/ruby_native.yml is rendered as ERB before it is parsed, so a
|
|
@@ -277,4 +278,13 @@ module RubyNative
|
|
|
277
278
|
.reject(&:empty?)
|
|
278
279
|
.map { |path| path.start_with?("/") ? path : "/#{path}" }
|
|
279
280
|
end
|
|
281
|
+
|
|
282
|
+
# A non-boolean, even the string "false", fails the native config decode on both platforms.
|
|
283
|
+
def self.normalize_analytics
|
|
284
|
+
return unless self.config.key?(:analytics)
|
|
285
|
+
return if [true, false].include?(self.config[:analytics])
|
|
286
|
+
|
|
287
|
+
Rails.logger.warn("[RubyNative] analytics in config/ruby_native.yml must be true or false; ignoring it.")
|
|
288
|
+
self.config.delete(:analytics)
|
|
289
|
+
end
|
|
280
290
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_native
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joe Masilotti
|
|
@@ -84,6 +84,7 @@ files:
|
|
|
84
84
|
- app/views/ruby_native/auth/start/show.html.erb
|
|
85
85
|
- config/importmap.rb
|
|
86
86
|
- config/routes.rb
|
|
87
|
+
- config/signals.yml
|
|
87
88
|
- exe/ruby_native
|
|
88
89
|
- lib/generators/ruby_native/iap_generator.rb
|
|
89
90
|
- lib/generators/ruby_native/install_generator.rb
|
|
@@ -93,6 +94,8 @@ files:
|
|
|
93
94
|
- lib/ruby_native.rb
|
|
94
95
|
- lib/ruby_native/certs/AppleRootCA-G3.cer
|
|
95
96
|
- lib/ruby_native/cli.rb
|
|
97
|
+
- lib/ruby_native/cli/check.rb
|
|
98
|
+
- lib/ruby_native/cli/check/signal_collector.rb
|
|
96
99
|
- lib/ruby_native/cli/credentials.rb
|
|
97
100
|
- lib/ruby_native/cli/deploy.rb
|
|
98
101
|
- lib/ruby_native/cli/login.rb
|
|
@@ -110,6 +113,7 @@ files:
|
|
|
110
113
|
- lib/ruby_native/oauth_middleware.rb
|
|
111
114
|
- lib/ruby_native/screenshots/sign_in_helper.rb
|
|
112
115
|
- lib/ruby_native/set_cookie_header.rb
|
|
116
|
+
- lib/ruby_native/signals.rb
|
|
113
117
|
- lib/ruby_native/tunnel_cookie_middleware.rb
|
|
114
118
|
- lib/ruby_native/version.rb
|
|
115
119
|
homepage: https://rubynative.com
|