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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +445 -0
- data/lib/fastlane/plugin/rustore_publish/actions/rustore_upload.rb +523 -0
- data/lib/fastlane/plugin/rustore_publish/certs/russian_trusted_root_ca.crt +33 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_auth.rb +179 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_client.rb +353 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_endpoint.rb +33 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_logger.rb +108 -0
- data/lib/fastlane/plugin/rustore_publish/version.rb +5 -0
- data/lib/fastlane/plugin/rustore_publish.rb +13 -0
- metadata +144 -0
|
@@ -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,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) }
|