fastlane-plugin-rustore_publish 0.3.1

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.
@@ -0,0 +1,179 @@
1
+ require "openssl"
2
+ require "base64"
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "rustore_logger"
7
+ require_relative "rustore_endpoint"
8
+
9
+ module Fastlane
10
+ module RustorePublish
11
+ # Handles authentication against the RuStore Public API.
12
+ #
13
+ # Flow:
14
+ # 1. Load RSA private key (PEM file or inline string).
15
+ # 2. Build request body: { keyId, timestamp, signature }
16
+ # where signature = Base64( RSA-SHA512( keyId + timestamp ) )
17
+ # 3. POST to /public/auth → receive a JWE token (TTL 900s).
18
+ # 4. Cache the token; auto-refresh when it's within REFRESH_BEFORE
19
+ # seconds of expiry.
20
+ #
21
+ # Reference: https://www.rustore.ru/help/en/work-with-rustore-api/api-authorization-token
22
+ class RustoreAuth
23
+ TOKEN_TTL = 900 # seconds (as documented by RuStore)
24
+ REFRESH_BEFORE = 60 # refresh the token this many seconds before expiry
25
+
26
+ def initialize(key_id:, private_key:, logger: nil)
27
+ @key_id = key_id
28
+ @logger = logger || RustoreLogger.new
29
+ @rsa_key = load_rsa_key(private_key)
30
+ @token = nil
31
+ @expires_at = nil
32
+ end
33
+
34
+ # Returns a valid JWE token, refreshing if necessary.
35
+ def token
36
+ refresh! if token_expired?
37
+ @token
38
+ end
39
+
40
+ # Force-fetches a fresh token regardless of cache state.
41
+ def refresh!
42
+ @logger.verbose("Requesting new JWE token from RuStore auth endpoint")
43
+ jwe = fetch_jwe_token
44
+ @token = jwe
45
+ @expires_at = Time.now + TOKEN_TTL - REFRESH_BEFORE
46
+ @logger.verbose("Token cached, valid for #{TOKEN_TTL}s (refresh in #{TOKEN_TTL - REFRESH_BEFORE}s)")
47
+ @token
48
+ end
49
+
50
+ private
51
+
52
+ def token_expired?
53
+ @token.nil? || @expires_at.nil? || Time.now >= @expires_at
54
+ end
55
+
56
+ # Builds the auth request body:
57
+ # keyId — API key identifier from RuStore Console
58
+ # timestamp — ISO 8601 with milliseconds and timezone offset
59
+ # signature — Base64-encoded RSA-SHA512 signature of (keyId + timestamp)
60
+ def build_auth_body
61
+ timestamp = build_timestamp
62
+ message = "#{@key_id}#{timestamp}"
63
+ digest = OpenSSL::Digest::SHA512.new
64
+ signature = Base64.strict_encode64(@rsa_key.sign(digest, message))
65
+
66
+ { keyId: @key_id, timestamp: timestamp, signature: signature }
67
+ end
68
+
69
+ # Formats the current time as ISO 8601 with milliseconds and UTC offset.
70
+ # Example: "2024-01-01T10:00:00.000+03:00"
71
+ def build_timestamp
72
+ t = Time.now
73
+ t.strftime("%Y-%m-%dT%H:%M:%S.%3N") + t.strftime("%:z")
74
+ end
75
+
76
+ # POST auth body to RuStore and return the JWE token string.
77
+ def fetch_jwe_token
78
+ uri = URI(RustoreEndpoint::AUTH_URL)
79
+
80
+ request = Net::HTTP::Post.new(uri)
81
+ request["Content-Type"] = "application/json"
82
+ request.body = JSON.generate(build_auth_body)
83
+
84
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, cert_store: RustoreEndpoint.cert_store) do |http|
85
+ http.read_timeout = 30
86
+ http.open_timeout = 10
87
+ http.request(request)
88
+ end
89
+
90
+ handle_auth_response(response)
91
+ rescue OpenSSL::SSL::SSLError => e
92
+ @logger.error(
93
+ "TLS handshake with #{RustoreEndpoint::HOST} failed: #{e.message}",
94
+ hint: "The plugin trusts the system roots plus the bundled Russian Trusted Root CA. " \
95
+ "Check that no proxy replaces the certificate and that the system CA store is readable (SSL_CERT_FILE)",
96
+ raise_error: true
97
+ )
98
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
99
+ @logger.error(
100
+ "RuStore did not respond to the auth request: #{e.class}",
101
+ hint: "Rerun the lane; if it keeps failing, check network access to #{RustoreEndpoint::HOST}:443",
102
+ raise_error: true
103
+ )
104
+ rescue SocketError, SystemCallError => e
105
+ @logger.error(
106
+ "Could not connect to #{RustoreEndpoint::HOST}: #{e.message}",
107
+ hint: "Check DNS, proxy and firewall from this machine to #{RustoreEndpoint::HOST}:443",
108
+ raise_error: true
109
+ )
110
+ end
111
+
112
+ def handle_auth_response(response)
113
+ unless response.is_a?(Net::HTTPSuccess)
114
+ body = safe_parse_json(response.body)
115
+ message = body&.dig("message") || response.body.to_s.slice(0, 300)
116
+ @logger.error(
117
+ "Authentication failed: #{message}",
118
+ hint: "Verify key_id and private_key are correct and the key has API access enabled in RuStore Console",
119
+ raise_error: true
120
+ )
121
+ end
122
+
123
+ body = safe_parse_json(response.body)
124
+ jwe = body&.dig("body", "jwe") || body&.dig("jwe")
125
+
126
+ if jwe.nil? || jwe.empty?
127
+ @logger.error(
128
+ "Authentication response did not contain a JWE token",
129
+ hint: "Unexpected API response format: #{response.body.to_s.slice(0, 200)}",
130
+ raise_error: true
131
+ )
132
+ end
133
+
134
+ jwe
135
+ end
136
+
137
+ def load_rsa_key(private_key)
138
+ pem = if File.exist?(private_key.to_s)
139
+ File.read(private_key)
140
+ else
141
+ private_key
142
+ end
143
+
144
+ if pem.strip.start_with?("-----")
145
+ OpenSSL::PKey::RSA.new(pem)
146
+ else
147
+ # Raw base64 without PEM headers (e.g. CI secret stored without BEGIN/END lines).
148
+ # Try PKCS#8 first (RuStore keys), then fall back to traditional PKCS#1.
149
+ load_bare_base64(pem.strip.gsub(/\s+/, ""))
150
+ end
151
+ rescue OpenSSL::PKey::RSAError => e
152
+ @logger.error(
153
+ "Failed to load RSA private key: #{e.message}",
154
+ hint: "Ensure the key is a valid RSA PEM — either a full PEM string " \
155
+ "(with -----BEGIN PRIVATE KEY----- header) or raw base64 content",
156
+ raise_error: true
157
+ )
158
+ end
159
+
160
+ # Tries to load a bare base64 DER blob by wrapping it in PEM headers.
161
+ # Attempts PKCS#8 first (RuStore generates PKCS#8 keys), then PKCS#1.
162
+ def load_bare_base64(b64)
163
+ body = b64.scan(/.{1,64}/).join("\n")
164
+
165
+ begin
166
+ OpenSSL::PKey::RSA.new("-----BEGIN PRIVATE KEY-----\n#{body}\n-----END PRIVATE KEY-----\n")
167
+ rescue OpenSSL::PKey::RSAError
168
+ OpenSSL::PKey::RSA.new("-----BEGIN RSA PRIVATE KEY-----\n#{body}\n-----END RSA PRIVATE KEY-----\n")
169
+ end
170
+ end
171
+
172
+ def safe_parse_json(str)
173
+ JSON.parse(str.to_s)
174
+ rescue StandardError
175
+ nil
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,353 @@
1
+ require "faraday"
2
+ require "faraday/multipart"
3
+ require "json"
4
+ require_relative "rustore_auth"
5
+ require_relative "rustore_logger"
6
+ require_relative "rustore_endpoint"
7
+
8
+ module Fastlane
9
+ module RustorePublish
10
+ # HTTP client for the RuStore Public API.
11
+ #
12
+ # All methods raise via RustoreLogger#error on non-2xx responses and on
13
+ # network failures, so that callers never need to inspect raw HTTP status
14
+ # codes or Faraday exceptions.
15
+ #
16
+ # Base URL: https://public-api-m.rustore.ru/public/v1 (see RustoreEndpoint)
17
+ class RustoreClient
18
+ # RuStore answers a file upload only after it has checked the file
19
+ # (permissions, SDKs), which can take minutes
20
+ DEFAULT_UPLOAD_TIMEOUT = 900
21
+ REQUEST_TIMEOUT = 300
22
+ WAIT_LOG_INTERVAL = 60
23
+
24
+ # Supported values for servicesType query param
25
+ SERVICES_TYPE_UNKNOWN = "Unknown"
26
+ SERVICES_TYPE_HMS = "HMS"
27
+
28
+ def initialize(auth:, logger: nil, upload_timeout: DEFAULT_UPLOAD_TIMEOUT, wait_log_interval: WAIT_LOG_INTERVAL)
29
+ @auth = auth
30
+ @logger = logger || RustoreLogger.new
31
+ @upload_timeout = upload_timeout
32
+ @wait_log_interval = wait_log_interval
33
+ @conn = build_connection
34
+ end
35
+
36
+ # ── Version Draft ───────────────────────────────────────────────────────
37
+
38
+ # POST /application/{package}/version
39
+ # Creates a new version draft and returns the versionId (Integer).
40
+ def create_draft(package_name:, **metadata)
41
+ resp = post(
42
+ "application/#{package_name}/version",
43
+ metadata
44
+ )
45
+ body = resp["body"]
46
+ version_id = if body.is_a?(Hash)
47
+ body["publishId"] || body["versionId"]
48
+ else
49
+ body # API returns the version ID directly as an integer
50
+ end
51
+
52
+ if version_id.nil?
53
+ @logger.error(
54
+ "RuStore API did not return a versionId after draft creation",
55
+ hint: "Response body: #{resp.inspect.slice(0, 300)}"
56
+ )
57
+ end
58
+
59
+ version_id.to_i
60
+ end
61
+
62
+ # GET /application/{package}/version?versionStatuses=DRAFT
63
+ # Returns the current draft if present.
64
+ def find_existing_draft(package_name:)
65
+ resp = get("application/#{package_name}/version?versionStatuses=DRAFT")
66
+ versions = resp.dig("body", "content") || resp["body"] || []
67
+ versions.find { |v| v["versionStatus"] == "DRAFT" }
68
+ end
69
+
70
+ # DELETE /application/{package}/version/{versionId}
71
+ def delete_draft(package_name:, version_id:)
72
+ delete("application/#{package_name}/version/#{version_id}")
73
+ end
74
+
75
+ # ── File Upload ─────────────────────────────────────────────────────────
76
+
77
+ # POST /application/{package}/version/{id}/aab
78
+ # Uploads an AAB file. The AAB is always treated as the main artifact.
79
+ #
80
+ # @param file_path [String] absolute path to the .aab file
81
+ # @return [Hash] parsed response (body has permissions and autoDetectedSdks)
82
+ def upload_aab(package_name:, version_id:, file_path:)
83
+ upload_file(
84
+ path: "application/#{package_name}/version/#{version_id}/aab",
85
+ version_id: version_id,
86
+ file_path: file_path,
87
+ content_type: "application/octet-stream"
88
+ )
89
+ end
90
+
91
+ # POST /application/{package}/version/{id}/apk
92
+ # Uploads an APK file.
93
+ #
94
+ # @param services_type [String] "Unknown" (default/GMS) or "HMS"
95
+ # @param is_main_apk [Boolean] true if this is the primary APK
96
+ # @return [Hash] parsed response (body has permissions and autoDetectedSdks)
97
+ def upload_apk(package_name:, version_id:, file_path:,
98
+ services_type: SERVICES_TYPE_UNKNOWN, is_main_apk: true)
99
+ upload_file(
100
+ path: "application/#{package_name}/version/#{version_id}/apk",
101
+ version_id: version_id,
102
+ file_path: file_path,
103
+ content_type: "application/vnd.android.package-archive",
104
+ query: { servicesType: services_type, isMainApk: is_main_apk }
105
+ )
106
+ end
107
+
108
+ # ── Submission ──────────────────────────────────────────────────────────
109
+
110
+ # POST /application/{package}/version/{id}/commit?priorityUpdate={n}
111
+ # Submits the draft for moderation.
112
+ #
113
+ # @param update_priority [Integer] in-app update priority, 0 (min) to 5 (max)
114
+ def submit_for_review(package_name:, version_id:, update_priority: 0)
115
+ post(
116
+ "application/#{package_name}/version/#{version_id}/commit?priorityUpdate=#{update_priority}",
117
+ {}
118
+ )
119
+ end
120
+
121
+ private
122
+
123
+ # ── HTTP Helpers ─────────────────────────────────────────────────────────
124
+
125
+ def get(path)
126
+ resp = with_network_errors(path) do
127
+ @conn.get(path) { |r| r.headers["Public-Token"] = @auth.token }
128
+ end
129
+ handle_response(resp, path)
130
+ end
131
+
132
+ def post(path, body)
133
+ resp = with_network_errors(path) do
134
+ @conn.post(path) do |r|
135
+ r.headers["Public-Token"] = @auth.token
136
+ r.headers["Content-Type"] = "application/json"
137
+ r.body = JSON.generate(body)
138
+ end
139
+ end
140
+ handle_response(resp, path)
141
+ end
142
+
143
+ def delete(path)
144
+ resp = with_network_errors(path) do
145
+ @conn.delete(path) { |r| r.headers["Public-Token"] = @auth.token }
146
+ end
147
+ handle_response(resp, path)
148
+ end
149
+
150
+ # Multipart file upload. Logs when the file has been sent, a heartbeat
151
+ # while RuStore is processing it, and the response time.
152
+ def upload_file(path:, version_id:, file_path:, content_type:, query: {})
153
+ filename = File.basename(file_path)
154
+ size_mb = (File.size(file_path) / 1_048_576.0).round(1)
155
+ url = query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
156
+
157
+ @logger.info("Sending #{filename} (#{size_mb} MB)")
158
+ started_at = monotonic_now
159
+ tracked_io = SendTrackingIO.new(file_path) do |finished_at|
160
+ @logger.info("#{filename} sent in #{(finished_at - started_at).round(1)}s, waiting for RuStore to check it")
161
+ end
162
+ upload = { filename: filename, version_id: version_id }
163
+ heartbeat = start_heartbeat(tracked_io, filename)
164
+
165
+ resp = with_network_errors(path, upload: upload) do
166
+ @conn.post(url) do |req|
167
+ req.options.timeout = @upload_timeout
168
+ req.headers["Public-Token"] = @auth.token
169
+ req.body = {
170
+ file: Faraday::UploadIO.new(tracked_io, content_type, filename)
171
+ }
172
+ end
173
+ end
174
+
175
+ sent_at = tracked_io.finished_at || started_at
176
+ @logger.info("RuStore responded in #{(monotonic_now - sent_at).round}s")
177
+
178
+ result = handle_response(resp, path)
179
+ log_detected_items(result["body"])
180
+ result
181
+ ensure
182
+ heartbeat&.kill&.join
183
+ tracked_io&.close
184
+ end
185
+
186
+ # Logs every @wait_log_interval seconds until the response arrives,
187
+ # so a long wait does not look like a hung job.
188
+ def start_heartbeat(tracked_io, filename)
189
+ Thread.new do
190
+ loop do
191
+ sleep(@wait_log_interval)
192
+ finished_at = tracked_io.finished_at
193
+ if finished_at.nil?
194
+ @logger.info("Still sending #{filename}: #{(tracked_io.bytes / 1_048_576.0).round(1)} MB so far")
195
+ next
196
+ end
197
+ @logger.info("Waiting for RuStore response: #{(monotonic_now - finished_at).round}s / #{@upload_timeout}s")
198
+ end
199
+ end
200
+ end
201
+
202
+ # RuStore returns the permissions and SDKs it found in the uploaded file.
203
+ def log_detected_items(body)
204
+ return unless body.is_a?(Hash)
205
+
206
+ permissions = (body["permissions"] || {}).flat_map do |_category, items|
207
+ Array(items).map { |item| item.is_a?(Hash) ? item["permission"] : item }
208
+ end.compact
209
+ @logger.info("Permissions detected by RuStore: #{permissions.join(', ')}") unless permissions.empty?
210
+
211
+ sdks = (body["autoDetectedSdks"] || {}).reject { |_category, names| Array(names).empty? }
212
+ unless sdks.empty?
213
+ @logger.info("SDKs detected by RuStore: " + sdks.map { |category, names| "#{category}: #{Array(names).join(', ')}" }.join("; "))
214
+ end
215
+ end
216
+
217
+ # Turns Faraday network exceptions into a logged error with a hint.
218
+ # SSLError is rescued first: in Faraday it is not a ConnectionFailed.
219
+ def with_network_errors(path, upload: nil)
220
+ yield
221
+ rescue Faraday::SSLError => e
222
+ @logger.error(
223
+ "TLS handshake with #{RustoreEndpoint::HOST} failed: #{e.message}",
224
+ hint: "The plugin trusts the system roots plus the bundled Russian Trusted Root CA. " \
225
+ "Check that no proxy replaces the certificate and that the system CA store is readable (SSL_CERT_FILE)"
226
+ )
227
+ rescue Faraday::TimeoutError => e
228
+ if upload
229
+ @logger.error(
230
+ "RuStore did not respond to the upload of #{upload[:filename]} within #{@upload_timeout}s " \
231
+ "(versionId=#{upload[:version_id]}): #{e.message}",
232
+ hint: "The draft stays in RuStore and is replaced on the next run, so rerunning the lane is safe. " \
233
+ "If RuStore is just slow, raise `upload_timeout` (RUSTORE_UPLOAD_TIMEOUT)"
234
+ )
235
+ else
236
+ @logger.error(
237
+ "RuStore did not respond to #{path} within #{REQUEST_TIMEOUT}s: #{e.message}",
238
+ hint: "Rerunning the lane is safe: an unfinished draft is replaced on the next run"
239
+ )
240
+ end
241
+ rescue Faraday::ConnectionFailed => e
242
+ @logger.error(
243
+ "Could not connect to #{RustoreEndpoint::HOST}: #{e.message}",
244
+ hint: "Check DNS, proxy and firewall from this machine to #{RustoreEndpoint::HOST}:443"
245
+ )
246
+ end
247
+
248
+ def handle_response(resp, path, raise_error: true)
249
+ body = safe_parse_json(resp.body)
250
+
251
+ unless resp.success?
252
+ api_message = body&.dig("message") || body&.dig("body", "message")
253
+ if api_message.nil? && resp.status >= 500
254
+ api_message = "RuStore returned a non-JSON response (#{resp.body.to_s.bytesize} bytes)"
255
+ end
256
+ api_message ||= resp.body.to_s.slice(0, 300)
257
+ api_message = "(empty response body)" if api_message.to_s.strip.empty?
258
+
259
+ if raise_error
260
+ @logger.error(
261
+ "API request failed [#{resp.status}] #{path}: #{api_message}",
262
+ hint: error_hint(resp.status, api_message)
263
+ )
264
+ else
265
+ @logger.warning("API request failed [#{resp.status}] #{path}: #{api_message}")
266
+ return nil
267
+ end
268
+ end
269
+
270
+ # RuStore sometimes returns 200 with code != "OK"
271
+ api_code = body&.dig("code")
272
+ if api_code && api_code != "OK" && api_code != "200"
273
+ api_message = body&.dig("message") || "code=#{api_code}"
274
+ @logger.error(
275
+ "API returned error code '#{api_code}' on #{path}: #{api_message}",
276
+ hint: error_hint(nil, api_message)
277
+ )
278
+ end
279
+
280
+ body || {}
281
+ end
282
+
283
+ def error_hint(status, message)
284
+ text = message.to_s
285
+ return "Upload the file in RuStore Console, fill in the permission declaration and submit the draft from there" if text.include?("new sensitive permissions")
286
+ return "Remove the listed permissions from the build and upload it again" if text.include?("illegal permissions")
287
+
288
+ case status
289
+ when 401 then "Token is invalid or expired — check key_id and private_key"
290
+ when 403 then "Key does not have permission for this operation — check RuStore Console API key settings"
291
+ when 404 then "Resource not found — verify package_name and that at least 1 active version exists in RuStore Console"
292
+ when 422 then "Validation error: #{message}"
293
+ when 429 then "Rate limited — wait before retrying"
294
+ when 500..599 then "Failure on the RuStore side; the draft is not submitted, rerunning the lane is safe"
295
+ else message
296
+ end
297
+ end
298
+
299
+ def build_connection
300
+ Faraday.new(url: RustoreEndpoint::BASE_URL, ssl: { cert_store: RustoreEndpoint.cert_store }) do |f|
301
+ f.request :multipart
302
+ f.request :url_encoded
303
+ f.response :logger, nil, { headers: false, bodies: false } if ENV["RUSTORE_DEBUG"]
304
+ f.adapter Faraday.default_adapter
305
+ f.options.timeout = REQUEST_TIMEOUT
306
+ f.options.open_timeout = 15
307
+ end
308
+ end
309
+
310
+ def safe_parse_json(str)
311
+ JSON.parse(str.to_s)
312
+ rescue StandardError
313
+ {}
314
+ end
315
+
316
+ def monotonic_now
317
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
318
+ end
319
+
320
+ # Wraps a file IO and records when the last byte has been read, i.e. the
321
+ # moment the file has been handed to the socket, and calls the block once
322
+ # at that moment. Fully compatible with Faraday::UploadIO (responds to
323
+ # read, size, eof?, rewind, close, path).
324
+ class SendTrackingIO
325
+ attr_reader :bytes, :finished_at
326
+
327
+ def initialize(file_path, &on_finish)
328
+ @io = File.open(file_path, "rb")
329
+ @total = File.size(file_path)
330
+ @bytes = 0
331
+ @finished_at = nil
332
+ @on_finish = on_finish
333
+ end
334
+
335
+ def read(length = nil, outbuf = nil)
336
+ chunk = length ? @io.read(length) : @io.read
337
+ @bytes += chunk.bytesize if chunk
338
+ if @finished_at.nil? && @bytes >= @total
339
+ @finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
340
+ @on_finish&.call(@finished_at)
341
+ end
342
+ outbuf ? (outbuf.replace(chunk || ""); outbuf) : chunk
343
+ end
344
+
345
+ def size; @total; end
346
+ def eof?; @io.eof?; end
347
+ def rewind; @io.rewind; @bytes = 0; @finished_at = nil; end
348
+ def close; @io.close unless @io.closed?; end
349
+ def path; @io.path; end
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,33 @@
1
+ require "openssl"
2
+
3
+ module Fastlane
4
+ module RustorePublish
5
+ # RuStore Public API host and TLS trust settings.
6
+ #
7
+ # RuStore moved the Public API to the "-m" domain, which is served with a
8
+ # certificate issued by the Russian Trusted Root CA (Ministry of Digital
9
+ # Development). That root is not in the default OS/Ruby trust stores, so the
10
+ # plugin ships it and adds it on top of the system roots.
11
+ #
12
+ # Reference: https://www.rustore.ru/developer/blog/rustore-obnovlyaet-sertifikaty-i-domeny-chto-nuzhno-sdelat-razrabotchikam
13
+ module RustoreEndpoint
14
+ HOST = "public-api-m.rustore.ru"
15
+ BASE_URL = "https://#{HOST}/public/v1"
16
+ AUTH_URL = "https://#{HOST}/public/auth"
17
+ CA_FILE = File.expand_path("../certs/russian_trusted_root_ca.crt", __dir__)
18
+
19
+ # System roots + Russian Trusted Root CA.
20
+ def self.cert_store
21
+ store = OpenSSL::X509::Store.new
22
+ store.set_default_paths
23
+ root = OpenSSL::X509::Certificate.new(File.read(CA_FILE))
24
+ begin
25
+ store.add_cert(root)
26
+ rescue OpenSSL::X509::StoreError
27
+ # Root is already present in the system store
28
+ end
29
+ store
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,108 @@
1
+ require "fastlane_core/ui/ui"
2
+
3
+ module Fastlane
4
+ module RustorePublish
5
+ # Structured logger for RuStore plugin.
6
+ #
7
+ # In standard mode outputs via FastlaneCore::UI.* helpers.
8
+ # When GITLAB_CI env var is set, wraps each step in GitLab CI
9
+ # collapsible section markers so they appear as foldable blocks
10
+ # in the Pipeline web UI.
11
+ #
12
+ # Usage:
13
+ # logger = RustoreLogger.new(total_steps: 7)
14
+ # logger.step(1, "Authentication")
15
+ # logger.success("Token obtained")
16
+ # logger.info("Expires in 900s")
17
+ # logger.error("Something failed", hint: "Check your key_id")
18
+ class RustoreLogger
19
+ PREFIX = "[RuStore]"
20
+
21
+ # ANSI / GitLab CI section escape sequences
22
+ SECTION_START = "\e[0Ksection_start:%<ts>d:%<name>s[collapsed=true]\r\e[0K"
23
+ SECTION_END = "\e[0Ksection_end:%<ts>d:%<name>s\r\e[0K"
24
+
25
+ UI = FastlaneCore::UI
26
+
27
+ def initialize(total_steps: 0)
28
+ @total_steps = total_steps
29
+ @gitlab_ci = ENV["GITLAB_CI"] == "true" || ENV["GITLAB_CI"] == "1"
30
+ @open_section = nil
31
+ end
32
+
33
+ # ── Step header ──────────────────────────────────────────────────────────
34
+
35
+ def step(number, title)
36
+ close_section if @open_section
37
+
38
+ label = @total_steps > 0 ? "Step #{number}/#{@total_steps}: #{title}" : title
39
+ section_name = "rustore_step_#{number}"
40
+
41
+ if @gitlab_ci
42
+ ts = Time.now.to_i
43
+ $stdout.print(format(SECTION_START, ts: ts, name: section_name) + "#{PREFIX} #{label}\n")
44
+ $stdout.flush
45
+ @open_section = { name: section_name }
46
+ else
47
+ UI.header("#{PREFIX} #{label}")
48
+ end
49
+ end
50
+
51
+ # ── Message levels ───────────────────────────────────────────────────────
52
+
53
+ def info(message)
54
+ UI.message("#{PREFIX} #{message}")
55
+ end
56
+
57
+ def success(message)
58
+ UI.success("#{PREFIX} ✓ #{message}")
59
+ end
60
+
61
+ def warning(message)
62
+ UI.important("#{PREFIX} ⚠ #{message}")
63
+ end
64
+
65
+ def verbose(message)
66
+ UI.verbose("#{PREFIX} #{message}")
67
+ end
68
+
69
+ # Logs a structured error with an optional actionable hint, then raises
70
+ # so the pipeline fails cleanly.
71
+ #
72
+ # @param message [String] what went wrong
73
+ # @param hint [String] how to fix it (optional)
74
+ # @param raise_error [Boolean] whether to raise after logging (default true)
75
+ def error(message, hint: nil, raise_error: true)
76
+ close_section if @open_section
77
+ UI.error("#{PREFIX} ✗ #{message}")
78
+ UI.error("#{PREFIX} → #{hint}") if hint
79
+ UI.user_error!(message) if raise_error
80
+ end
81
+
82
+ # Logs a key/value table for step context (e.g. upload params)
83
+ def table(rows)
84
+ max_key = rows.map { |k, _| k.to_s.length }.max || 0
85
+ rows.each do |key, value|
86
+ UI.message("#{PREFIX} %-#{max_key}s : %s" % [key, value])
87
+ end
88
+ end
89
+
90
+ # Close the currently open GitLab CI section (called automatically
91
+ # before each new step and at the end of the workflow).
92
+ def finalize
93
+ close_section
94
+ end
95
+
96
+ private
97
+
98
+ def close_section
99
+ return unless @gitlab_ci && @open_section
100
+
101
+ ts = Time.now.to_i
102
+ $stdout.print(format(SECTION_END, ts: ts, name: @open_section[:name]) + "\n")
103
+ $stdout.flush
104
+ @open_section = nil
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module RustorePublish
3
+ VERSION = "0.3.1"
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ require "fastlane/plugin/rustore_publish/version"
2
+
3
+ module Fastlane
4
+ module RustorePublish
5
+ # Returns all actions in the plugin
6
+ def self.all_classes
7
+ Dir[File.expand_path("rustore_publish/**/*.rb", __dir__)]
8
+ end
9
+ end
10
+ end
11
+
12
+ # Autoload all actions and helpers
13
+ Fastlane::RustorePublish.all_classes.each { |c| require(c) }